Skip to content

Commit 7df3100

Browse files
authored
Fix Connect chat validation kicks on Velocity and Spigot
Merge PR #26: unsigned chat-session mitigation for Connect tunnels on Velocity and Spigot/Paper.
1 parent d9ae896 commit 7df3100

10 files changed

Lines changed: 593 additions & 2 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
* THE SOFTWARE.
21+
*
22+
* @author GeyserMC
23+
* @link https://github.com/GeyserMC/Floodgate
24+
*/
25+
26+
package com.minekube.connect.addon.data;
27+
28+
import io.netty.channel.ChannelHandlerContext;
29+
import io.netty.channel.ChannelInboundHandlerAdapter;
30+
import io.netty.util.ReferenceCountUtil;
31+
import java.lang.reflect.Constructor;
32+
import java.lang.reflect.Method;
33+
import java.time.Instant;
34+
import java.util.BitSet;
35+
36+
final class SpigotChatSessionPacketFilter extends ChannelInboundHandlerAdapter {
37+
static final String HANDLER_NAME = "connect_chat_session_filter";
38+
39+
private static final String CHAT_SESSION_UPDATE_PACKET =
40+
"net.minecraft.network.protocol.game.ServerboundChatSessionUpdatePacket";
41+
private static final String CHAT_ACK_PACKET =
42+
"net.minecraft.network.protocol.game.ServerboundChatAckPacket";
43+
private static final String SIGNED_COMMAND_PACKET =
44+
"net.minecraft.network.protocol.game.ServerboundChatCommandSignedPacket";
45+
private static final String UNSIGNED_COMMAND_PACKET =
46+
"net.minecraft.network.protocol.game.ServerboundChatCommandPacket";
47+
private static final String CHAT_PACKET =
48+
"net.minecraft.network.protocol.game.ServerboundChatPacket";
49+
private static final String MESSAGE_SIGNATURE =
50+
"net.minecraft.network.chat.MessageSignature";
51+
private static final String LAST_SEEN_UPDATE =
52+
"net.minecraft.network.chat.LastSeenMessages$Update";
53+
54+
@Override
55+
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
56+
Object replacement = rewrite(msg);
57+
if (replacement == null) {
58+
ReferenceCountUtil.release(msg);
59+
return;
60+
}
61+
super.channelRead(ctx, replacement);
62+
}
63+
64+
private static Object rewrite(Object packet) throws Exception {
65+
if (isInstance(CHAT_SESSION_UPDATE_PACKET, packet) || isInstance(CHAT_ACK_PACKET, packet)) {
66+
return null;
67+
}
68+
if (isInstance(SIGNED_COMMAND_PACKET, packet)) {
69+
return rewriteSignedCommand(packet);
70+
}
71+
if (isInstance(CHAT_PACKET, packet)) {
72+
return rewriteChatLastSeen(packet);
73+
}
74+
return packet;
75+
}
76+
77+
private static Object rewriteSignedCommand(Object packet) throws Exception {
78+
String command = (String) invoke(packet, "command");
79+
Constructor<?> constructor = classForName(UNSIGNED_COMMAND_PACKET)
80+
.getConstructor(String.class);
81+
return constructor.newInstance(command);
82+
}
83+
84+
private static Object rewriteChatLastSeen(Object packet) throws Exception {
85+
Constructor<?> constructor = classForName(CHAT_PACKET).getConstructor(
86+
String.class,
87+
Instant.class,
88+
long.class,
89+
classForName(MESSAGE_SIGNATURE),
90+
classForName(LAST_SEEN_UPDATE)
91+
);
92+
return constructor.newInstance(
93+
invoke(packet, "message"),
94+
invoke(packet, "timeStamp"),
95+
invoke(packet, "salt"),
96+
invoke(packet, "signature"),
97+
emptyLastSeenUpdate()
98+
);
99+
}
100+
101+
private static Object emptyLastSeenUpdate() throws Exception {
102+
Constructor<?> constructor = classForName(LAST_SEEN_UPDATE)
103+
.getConstructor(int.class, BitSet.class, byte.class);
104+
return constructor.newInstance(0, new BitSet(), (byte) 0);
105+
}
106+
107+
private static Object invoke(Object target, String methodName) throws Exception {
108+
Method method = target.getClass().getMethod(methodName);
109+
return method.invoke(target);
110+
}
111+
112+
private static boolean isInstance(String className, Object value) {
113+
try {
114+
return classForName(className).isInstance(value);
115+
} catch (ClassNotFoundException ignored) {
116+
return false;
117+
}
118+
}
119+
120+
private static Class<?> classForName(String className) throws ClassNotFoundException {
121+
return Class.forName(className);
122+
}
123+
}

spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ public void onInject(Channel channel, boolean toServer) {
5656
config,
5757
logger)
5858
);
59+
if (channel.pipeline().get(SpigotChatSessionPacketFilter.HANDLER_NAME) == null) {
60+
channel.pipeline().addBefore(
61+
packetHandlerName,
62+
SpigotChatSessionPacketFilter.HANDLER_NAME,
63+
new SpigotChatSessionPacketFilter()
64+
);
65+
}
5966
});
6067
}
6168

velocity/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ java {
1212
dependencies {
1313
api(projects.core)
1414
implementation("cloud.commandframework", "cloud-velocity", Versions.cloudVersion)
15+
16+
testImplementation("org.junit.jupiter:junit-jupiter:5.10.5")
17+
testImplementation("io.netty", "netty-transport", Versions.nettyVersion)
18+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
1519
}
1620

1721
relocate("cloud.commandframework")
1822
// used in cloud
1923
relocate("io.leangen.geantyref")
2024

25+
tasks.test {
26+
useJUnitPlatform()
27+
}
28+
2129

2230
// these dependencies are already present on the platform
2331
provided("com.google.code.gson", "gson", gsonVersion)
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*
2+
* Copyright (c) 2021-2022 Minekube. https://minekube.com
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
* THE SOFTWARE.
21+
*
22+
* @author Minekube
23+
* @link https://github.com/minekube/connect-java
24+
*/
25+
26+
package com.minekube.connect.inject.velocity;
27+
28+
import io.netty.buffer.ByteBuf;
29+
import io.netty.channel.Channel;
30+
import io.netty.channel.ChannelHandlerContext;
31+
import io.netty.channel.ChannelInboundHandlerAdapter;
32+
import io.netty.channel.ChannelPipeline;
33+
import io.netty.util.ReferenceCountUtil;
34+
import java.lang.reflect.Field;
35+
import java.time.Instant;
36+
import java.util.Map;
37+
import java.util.concurrent.ConcurrentHashMap;
38+
39+
final class VelocityChatSessionPacketFilter extends ChannelInboundHandlerAdapter {
40+
static final String HANDLER_NAME = "connect_chat_session_filter";
41+
42+
private static final String MINECRAFT_DECODER = "minecraft-decoder";
43+
private static final String PLAY_STATE = "PLAY";
44+
private static final String SESSION_CHAT_PACKET =
45+
"com.velocitypowered.proxy.protocol.packet.chat.session.SessionPlayerChatPacket";
46+
private static final String SESSION_COMMAND_PACKET =
47+
"com.velocitypowered.proxy.protocol.packet.chat.session.SessionPlayerCommandPacket";
48+
private static final String UNSIGNED_COMMAND_PACKET =
49+
"com.velocitypowered.proxy.protocol.packet.chat.session.UnsignedPlayerCommandPacket";
50+
private static final String CHAT_ACKNOWLEDGEMENT_PACKET =
51+
"com.velocitypowered.proxy.protocol.packet.chat.ChatAcknowledgementPacket";
52+
private static final Instant EARLIEST_REASONABLE_CHAT_TIMESTAMP = Instant.EPOCH;
53+
private static final Map<String, Field> FIELDS = new ConcurrentHashMap<>();
54+
55+
private final boolean connectTunnel;
56+
private final ChatSessionPacketIdResolver resolver;
57+
58+
VelocityChatSessionPacketFilter(boolean connectTunnel) {
59+
this(connectTunnel, VelocityChatSessionPacketFilter::resolveChatSessionPacketId);
60+
}
61+
62+
VelocityChatSessionPacketFilter(boolean connectTunnel, ChatSessionPacketIdResolver resolver) {
63+
this.connectTunnel = connectTunnel;
64+
this.resolver = resolver;
65+
}
66+
67+
static void inject(Channel channel, boolean connectTunnel) {
68+
if (!connectTunnel) {
69+
return;
70+
}
71+
72+
ChannelPipeline pipeline = channel.pipeline();
73+
if (pipeline.get(MINECRAFT_DECODER) == null || pipeline.get(HANDLER_NAME) != null) {
74+
return;
75+
}
76+
77+
pipeline.addAfter(MINECRAFT_DECODER, HANDLER_NAME,
78+
new VelocityChatSessionPacketFilter(true));
79+
}
80+
81+
@Override
82+
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
83+
Object sanitized = sanitizeCommandPacket(msg);
84+
if (sanitized != msg) {
85+
msg = sanitized;
86+
}
87+
88+
if (connectTunnel && hasClassName(msg, CHAT_ACKNOWLEDGEMENT_PACKET)) {
89+
ReferenceCountUtil.release(msg);
90+
return;
91+
}
92+
93+
if (connectTunnel && msg instanceof ByteBuf && shouldDrop(ctx, (ByteBuf) msg)) {
94+
((ByteBuf) msg).release();
95+
return;
96+
}
97+
98+
ctx.fireChannelRead(msg);
99+
}
100+
101+
private static boolean hasClassName(Object value, String className) {
102+
return value != null && className.equals(value.getClass().getName());
103+
}
104+
105+
private Object sanitizeCommandPacket(Object msg) {
106+
if (!connectTunnel || !hasClassName(msg, SESSION_COMMAND_PACKET)) {
107+
return msg;
108+
}
109+
110+
try {
111+
Object timestamp = fieldValue(msg, "timeStamp");
112+
if (!(timestamp instanceof Instant)
113+
|| !((Instant) timestamp).isBefore(EARLIEST_REASONABLE_CHAT_TIMESTAMP)) {
114+
return msg;
115+
}
116+
117+
Object command = fieldValue(msg, "command");
118+
if (!(command instanceof String)) {
119+
return msg;
120+
}
121+
122+
Class<?> unsignedCommandClass = Class.forName(UNSIGNED_COMMAND_PACKET,
123+
true, msg.getClass().getClassLoader());
124+
Object unsignedCommand = unsignedCommandClass.getDeclaredConstructor().newInstance();
125+
setFieldValue(unsignedCommand, "command", command);
126+
return unsignedCommand;
127+
} catch (ReflectiveOperationException | IllegalStateException ignored) {
128+
return msg;
129+
}
130+
}
131+
132+
private boolean shouldDrop(ChannelHandlerContext ctx, ByteBuf packet) {
133+
int chatSessionPacketId = resolver.resolve(ctx);
134+
if (chatSessionPacketId < 0) {
135+
return false;
136+
}
137+
138+
ByteBuf duplicate = packet.duplicate();
139+
int packetId = readVarInt(duplicate);
140+
return packetId == chatSessionPacketId;
141+
}
142+
143+
private static int resolveChatSessionPacketId(ChannelHandlerContext ctx) {
144+
try {
145+
Object decoder = ctx.pipeline().get(MINECRAFT_DECODER);
146+
if (decoder == null) {
147+
return -1;
148+
}
149+
150+
Object state = fieldValue(decoder, "state");
151+
if (!PLAY_STATE.equals(String.valueOf(state))) {
152+
return -1;
153+
}
154+
155+
Object registry = fieldValue(decoder, "registry");
156+
Object packetClassToId = fieldValue(registry, "packetClassToId");
157+
if (!(packetClassToId instanceof Map)) {
158+
return -1;
159+
}
160+
161+
for (Map.Entry<?, ?> entry : ((Map<?, ?>) packetClassToId).entrySet()) {
162+
Object key = entry.getKey();
163+
if (key instanceof Class
164+
&& SESSION_CHAT_PACKET.equals(((Class<?>) key).getName())
165+
&& entry.getValue() instanceof Number) {
166+
return ((Number) entry.getValue()).intValue() + 1;
167+
}
168+
}
169+
} catch (ReflectiveOperationException | IllegalStateException ignored) {
170+
return -1;
171+
}
172+
173+
return -1;
174+
}
175+
176+
private static Object fieldValue(Object target, String fieldName) throws ReflectiveOperationException {
177+
Field field = field(target.getClass(), fieldName);
178+
return field.get(target);
179+
}
180+
181+
private static void setFieldValue(Object target, String fieldName, Object value)
182+
throws ReflectiveOperationException {
183+
Field field = field(target.getClass(), fieldName);
184+
field.set(target, value);
185+
}
186+
187+
private static Field field(Class<?> type, String fieldName) {
188+
String key = type.getName() + "#" + fieldName;
189+
return FIELDS.computeIfAbsent(key, $ -> {
190+
try {
191+
Class<?> current = type;
192+
while (current != null) {
193+
try {
194+
Field field = current.getDeclaredField(fieldName);
195+
field.setAccessible(true);
196+
return field;
197+
} catch (NoSuchFieldException ignored) {
198+
current = current.getSuperclass();
199+
}
200+
}
201+
throw new NoSuchFieldException(fieldName);
202+
} catch (NoSuchFieldException exception) {
203+
throw new IllegalStateException(exception);
204+
}
205+
});
206+
}
207+
208+
private static int readVarInt(ByteBuf buf) {
209+
int value = 0;
210+
int position = 0;
211+
byte currentByte;
212+
213+
do {
214+
currentByte = buf.readByte();
215+
value |= (currentByte & 0x7F) << position;
216+
position += 7;
217+
} while ((currentByte & 0x80) != 0 && position < 35);
218+
219+
return value;
220+
}
221+
222+
interface ChatSessionPacketIdResolver {
223+
int resolve(ChannelHandlerContext ctx);
224+
}
225+
}

0 commit comments

Comments
 (0)