Last active
December 25, 2022 22:47
-
-
Save aadnk/7017936 to your computer and use it in GitHub Desktop.
Intercept the TAB packet without ProtocolLib. Disallow tab completion of command names, but not parameters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.comphenix.example; | |
import org.bukkit.plugin.java.JavaPlugin; | |
public class ExampleMod extends JavaPlugin { | |
private TabInterceptor interceptor; | |
@Override | |
public void onEnable() { | |
// Use ProtocolLib if its present | |
if (getServer().getPluginManager().getPlugin("ProtocolLib") != null) { | |
interceptor = new ProtocolTabInterceptor(this); | |
} else { | |
interceptor = new PacketTabInterceptor(this); | |
} | |
} | |
@Override | |
public void onDisable() { | |
if (interceptor != null) { | |
interceptor.close(); | |
interceptor = null; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.comphenix.example; | |
import java.lang.ref.WeakReference; | |
import java.lang.reflect.Field; | |
import java.lang.reflect.Method; | |
import java.lang.reflect.Modifier; | |
import java.util.Arrays; | |
import java.util.Collection; | |
import java.util.List; | |
import javax.annotation.Nullable; | |
import org.bukkit.Bukkit; | |
import org.bukkit.entity.Player; | |
import org.bukkit.event.EventHandler; | |
import org.bukkit.event.HandlerList; | |
import org.bukkit.event.Listener; | |
import org.bukkit.event.player.PlayerLoginEvent; | |
import org.bukkit.event.player.PlayerQuitEvent; | |
import org.bukkit.plugin.Plugin; | |
import com.google.common.base.Function; | |
import com.google.common.base.Predicates; | |
import com.google.common.collect.ArrayListMultimap; | |
import com.google.common.collect.Collections2; | |
import com.google.common.collect.ForwardingList; | |
import com.google.common.collect.Multimap; | |
class PacketTabInterceptor extends TabInterceptor implements Listener { | |
/** | |
* Represents a list that intercepts all insertions. | |
* @author Kristian | |
*/ | |
private class ProxyList extends ForwardingList<Object> { | |
private Player player; | |
private List<Object> original; | |
public ProxyList(Player player, List<Object> original) { | |
this.player = player; | |
this.original = original; | |
} | |
@Override | |
protected List<Object> delegate() { | |
return original; | |
} | |
@Override | |
public boolean add(Object element) { | |
add(size(), element); | |
return true; | |
} | |
@Override | |
public boolean addAll(Collection<? extends Object> collection) { | |
return super.addAll(process(collection)); | |
} | |
@Override | |
public void add(int index, Object element) { | |
final Object packet = interceptPacket(player, element); | |
if (packet != null) { | |
super.add(index, packet); | |
} | |
} | |
@Override | |
public Object set(int index, Object element) { | |
final Object packet = interceptPacket(player, element); | |
if (packet != null) { | |
return super.set(index, packet); | |
} | |
return get(index); | |
} | |
@Override | |
public boolean addAll(int index, Collection<? extends Object> elements) { | |
return super.addAll(index, process(elements)); | |
} | |
private Collection<Object> process(Collection<? extends Object> iterable) { | |
// Transform -> Apply our intercept function to each element in the list | |
// Filter -> Remove all null packets | |
return Collections2.filter(Collections2.transform(iterable, new Function<Object, Object>() { | |
@Override | |
public Object apply(@Nullable Object packet) { | |
return interceptPacket(player, packet); | |
} | |
}), Predicates.notNull()); | |
} | |
} | |
/** | |
* Represents a field set operation. | |
* @author Kristian | |
*/ | |
private static class FieldSetter { | |
private final Field field; | |
private final Object target; | |
private final List<Object> applyValue; | |
// The old value | |
private final WeakReference<List<Object>> creationValue; | |
public static FieldSetter from(Field field, Object target, List<Object> value) throws IllegalAccessException { | |
return new FieldSetter(field, target, value); | |
} | |
private FieldSetter(Field field, Object target, List<Object> value) throws IllegalAccessException { | |
this.field = field; | |
this.target = target; | |
this.applyValue = value; | |
this.creationValue = new WeakReference<List<Object>>(getCurrentValue()); | |
} | |
/** | |
* Retrieve the current value of the field. | |
* @return The current value. | |
*/ | |
@SuppressWarnings("unchecked") | |
public List<Object> getCurrentValue() throws IllegalAccessException { | |
return (List<Object>) field.get(target); | |
} | |
/** | |
* Retrieve the value of the field as it appeared when the setter was created. | |
* @return The creation value. | |
*/ | |
public List<Object> getCreationValue() { | |
return creationValue.get(); | |
} | |
/** | |
* Apply the field operation. | |
* @return A field setter that reverts this operation. | |
* @throws IllegalArgumentException Cannot assign a value of this type. | |
*/ | |
public FieldSetter apply() throws IllegalArgumentException { | |
try { | |
field.set(target, applyValue); | |
return new FieldSetter(field, target, getCreationValue()); | |
} catch (IllegalAccessException e) { | |
throw new RuntimeException("Unable to access field.", e); | |
} | |
} | |
} | |
// Method for injecting players | |
private Method getHandleMethod; | |
private Field connectionField; | |
private Field networkField; | |
private Field highPriorityQueueField; | |
private Field lowPriorityQueueField; | |
private Field completedCommandField; | |
// The packet class | |
private Class<?> tabCompletePacket; | |
private Multimap<Player, FieldSetter> revertOperations = ArrayListMultimap.create(); | |
// The parent plugin | |
private Plugin plugin; | |
// Whether or not we have detected interfering plugins | |
private boolean detectedInterference; | |
public PacketTabInterceptor(Plugin plugin) { | |
// Register this as a listener | |
this.plugin = plugin; | |
plugin.getServer().getPluginManager().registerEvents(this, plugin); | |
} | |
/** | |
* Invoked when we have intercepted a packet. | |
* @param packet - the packet to intercept, or NULL to skip. | |
*/ | |
private Object interceptPacket(Player player, Object packet) { | |
Class<?> clazz = packet.getClass(); | |
if (tabCompletePacket == null && clazz.getSimpleName().equals("Packet203TabComplete")) { | |
tabCompletePacket = clazz; | |
} | |
if (tabCompletePacket != null && tabCompletePacket.isAssignableFrom(clazz)) { | |
// Find the first string field in the packet | |
if (completedCommandField == null) { | |
for (Field field : tabCompletePacket.getDeclaredFields()) { | |
if (field.getType().equals(String.class)) { | |
completedCommandField = field; | |
completedCommandField.setAccessible(true); | |
} | |
} | |
} | |
try { | |
// Filter the packet if the completion is not allowed | |
if (isCompletionCancelled(player, (String) completedCommandField.get(packet))) { | |
return null; | |
} | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
return packet; | |
} | |
@SuppressWarnings("unchecked") | |
private void injectPlayer(Player player) throws Exception { | |
// Cannot inject twice | |
if (revertOperations.containsKey(player)) | |
throw new IllegalArgumentException("Cannot inject "+ player + "twice"); | |
Object nmsPlayer = getNmsPlayer(player); | |
if (connectionField == null) | |
connectionField = getField(nmsPlayer, nmsPlayer.getClass(), "playerConnection"); | |
Object connection = connectionField.get(nmsPlayer); | |
if (networkField == null) | |
networkField = getField(connection, connection.getClass(), "networkManager"); | |
Object networkManager = networkField.get(connection); | |
if (highPriorityQueueField == null) | |
highPriorityQueueField = getField(networkManager, networkManager.getClass(), "highPriorityQueue"); | |
if (lowPriorityQueueField == null) | |
lowPriorityQueueField = getField(networkManager, networkManager.getClass(), "lowPriorityQueue"); | |
List<Object> highPriorityQueue = (List<Object>) highPriorityQueueField.get(networkManager); | |
List<Object> lowPriorityQueue = (List<Object>) lowPriorityQueueField.get(networkManager); | |
// Proxy the lists | |
revertOperations.put(player, | |
FieldSetter.from(highPriorityQueueField, networkManager, new ProxyList(player, highPriorityQueue)).apply() | |
); | |
revertOperations.put(player, | |
FieldSetter.from(lowPriorityQueueField, networkManager, new ProxyList(player, lowPriorityQueue)).apply() | |
); | |
} | |
private void uninjectPlayer(Player player) { | |
for (FieldSetter setter : revertOperations.removeAll(player)) { | |
setter.apply(); | |
} | |
} | |
private Object getNmsPlayer(Player player) throws Exception { | |
if (getHandleMethod == null) { | |
getHandleMethod = getMethod(0, Modifier.STATIC, player.getClass(), "getHandle"); | |
} | |
return getHandleMethod.invoke(player); | |
} | |
@Override | |
public void close() { | |
// Clear as a listener | |
HandlerList.unregisterAll(this); | |
// Revert all proxy lists | |
for (FieldSetter setter : revertOperations.values()) { | |
setter.apply(); | |
} | |
revertOperations.clear(); | |
} | |
@EventHandler | |
public void onPlayerLogin(PlayerLoginEvent e) { | |
final Player player = e.getPlayer(); | |
// Wait until the playerConnection has been assigned | |
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, new Runnable() { | |
@Override | |
public void run() { | |
try { | |
injectPlayer(player); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
}, 1L); | |
// Wait a second and see if the lists remain the same | |
if (!detectedInterference) { | |
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, new Runnable() { | |
@Override | |
public void run() { | |
// Check every field | |
for (FieldSetter setter : revertOperations.get(player)) { | |
try { | |
if (setter.getCreationValue() != setter.getCurrentValue()) { | |
detectInterference(setter); | |
return; | |
} | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
} | |
}, 20L); | |
} | |
} | |
@EventHandler | |
public void onPlayerLogout(PlayerQuitEvent e) { | |
uninjectPlayer(e.getPlayer()); | |
} | |
/** | |
* Invoked when we have detected interference. | |
*/ | |
protected void detectInterference(FieldSetter setter) throws IllegalAccessException { | |
plugin.getLogger().warning("Detected interfering plugin(s). Field value: " + setter.getCurrentValue()); | |
plugin.getLogger().warning("Please install ProtocolLib."); | |
detectedInterference = true; | |
} | |
/** | |
* Search for the first publically and privately defined method of the given name and parameter count. | |
* @param requireMod - modifiers that are required. | |
* @param bannedMod - modifiers that are banned. | |
* @param clazz - a class to start with. | |
* @param methodName - the method name, or NULL to skip. | |
* @param paramCount - the expected parameter count. | |
* @return The first method by this name. | |
* @throws IllegalStateException If we cannot find this method. | |
*/ | |
private static Method getMethod(int requireMod, int bannedMod, Class<?> clazz, String methodName, Class<?>... params) { | |
for (Method method : clazz.getDeclaredMethods()) { | |
// Limitation: Doesn't handle overloads | |
if ((method.getModifiers() & requireMod) == requireMod && | |
(method.getModifiers() & bannedMod) == 0 && | |
(methodName == null || method.getName().equals(methodName)) && | |
Arrays.equals(method.getParameterTypes(), params)) { | |
method.setAccessible(true); | |
return method; | |
} | |
} | |
// Search in every superclass | |
if (clazz.getSuperclass() != null) | |
return getMethod(requireMod, bannedMod, clazz.getSuperclass(), methodName, params); | |
throw new IllegalStateException(String.format( | |
"Unable to find method %s (%s).", methodName, Arrays.asList(params))); | |
} | |
/** | |
* Search for the first publically and privately defined field of the given name. | |
* @param instance - an instance of the class with the field. | |
* @param clazz - an optional class to start with, or NULL to deduce it from instance. | |
* @param fieldName - the field name. | |
* @return The first field by this name. | |
* @throws IllegalStateException If we cannot find this field. | |
*/ | |
private static Field getField(Object instance, Class<?> clazz, String fieldName) { | |
if (clazz == null) | |
clazz = instance.getClass(); | |
// Ignore access rules | |
for (Field field : clazz.getDeclaredFields()) { | |
if (field.getName().equals(fieldName)) { | |
field.setAccessible(true); | |
return field; | |
} | |
} | |
// Recursively find the correct field | |
if (clazz.getSuperclass() != null) | |
return getField(instance, clazz.getSuperclass(), fieldName); | |
throw new IllegalStateException("Unable to find field " + fieldName + " in " + instance); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.comphenix.example; | |
import org.bukkit.plugin.Plugin; | |
import com.comphenix.protocol.Packets; | |
import com.comphenix.protocol.ProtocolLibrary; | |
import com.comphenix.protocol.events.ConnectionSide; | |
import com.comphenix.protocol.events.PacketAdapter; | |
import com.comphenix.protocol.events.PacketEvent; | |
import com.comphenix.protocol.events.PacketListener; | |
public class ProtocolTabInterceptor extends TabInterceptor { | |
private PacketListener listener; | |
public ProtocolTabInterceptor(Plugin plugin) { | |
ProtocolLibrary.getProtocolManager().addPacketListener( | |
listener = new PacketAdapter(plugin, ConnectionSide.SERVER_SIDE, Packets.Server.TAB_COMPLETE) { | |
@Override | |
public void onPacketSending(PacketEvent event) { | |
// Cancel the packet if it is not permitted | |
event.setCancelled(isCompletionCancelled( | |
event.getPlayer(), | |
event.getPacket().getStrings().read(0) | |
)); | |
} | |
}); | |
} | |
@Override | |
public void close() { | |
if (listener != null) { | |
ProtocolLibrary.getProtocolManager().removePacketListener(listener); | |
listener = null; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// OPTIONAL - Depends on Spigot 1.6.4 instead of CraftBukkit! | |
package com.comphenix.example; | |
import java.lang.reflect.Field; | |
import java.util.List; | |
import java.util.Map; | |
import net.minecraft.server.v1_6_R3.Connection; | |
import net.minecraft.server.v1_6_R3.INetworkManager; | |
import net.minecraft.server.v1_6_R3.Packet; | |
import net.minecraft.server.v1_6_R3.Packet203TabComplete; | |
import net.minecraft.server.v1_6_R3.PlayerConnection; | |
import org.bukkit.entity.Player; | |
import org.bukkit.plugin.Plugin; | |
import org.spigotmc.netty.PacketListener; | |
import com.google.common.collect.Lists; | |
public class SpigotInterceptor extends TabInterceptor { | |
private PacketListener listener; | |
// Spigot forces us to use NMS classes, which means you'll have to recompile your plugin next | |
// time Minecraft updates. Though CraftBukkit is really to blame here: | |
// http://forums.bukkit.org/threads/safeguard-versioning-policy.123435/ | |
public SpigotInterceptor(Plugin plugin) { | |
PacketListener.register(listener = new PacketListener() { | |
@Override | |
public Packet packetQueued(INetworkManager networkManager, Connection connection, Packet packet) { | |
if (!(connection instanceof PlayerConnection)) | |
return packet; | |
if (!(packet instanceof Packet203TabComplete)) | |
return packet; | |
// Avoiding reflection does make some things a lot easier. | |
Player player = ((PlayerConnection) connection).player.getBukkitEntity(); | |
String message = ((Packet203TabComplete) packet).d(); | |
if (isCompletionCancelled(player, message)) { | |
return null; | |
} else { | |
return packet; | |
} | |
} | |
}, plugin); | |
} | |
@Override | |
public void close() { | |
Class<?> clazz = PacketListener.class; | |
// Yeah ... there's no easy way to remove the listener | |
synchronized (PacketListener.class) { | |
try { | |
Field listenersField = getStaticField(clazz, "listeners"); | |
Field bakedField = getStaticField(clazz, "baked"); | |
@SuppressWarnings("unchecked") | |
Map<PacketListener, Plugin> listenerMap = (Map<PacketListener, Plugin>) listenersField.get(null); | |
List<PacketListener> listenerArray = Lists.newArrayList((PacketListener[]) bakedField.get(null)); | |
listenerMap.remove(listener); | |
listenerArray.remove(listener); | |
// Save the array back | |
bakedField.set(null, listenerArray.toArray(new PacketListener[0])); | |
} catch (Exception e) { | |
throw new RuntimeException("Cannot clean up listener.", e); | |
} | |
} | |
} | |
private Field getStaticField(Class<?> clazz, String fieldName) throws Exception { | |
Field field = clazz.getDeclaredField(fieldName); | |
field.setAccessible(true); | |
return field; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.comphenix.example; | |
import org.bukkit.entity.Player; | |
import com.google.common.base.Splitter; | |
public abstract class TabInterceptor { | |
/** | |
* Determine if the given player is permitted to see the completed command (due to TAB). | |
* @param player - the player. | |
* @param completedCommand - the completed command. | |
* @return TRUE if it is, FALSE otherwise. | |
*/ | |
public boolean isCompletionCancelled(Player player, String completedCommand) { | |
// Don't disable this for OPs | |
if (player.hasPermission("example.exempt")) | |
return false; | |
if (completedCommand == null) | |
return false; | |
// They must all contain parameters | |
for (String complete : Splitter.on((char)0).split(completedCommand)) { | |
if (complete.startsWith("/") && !complete.contains(" ")) { | |
return true; | |
} | |
} | |
return false; | |
} | |
/** | |
* Close the current interceptor. | |
* <p> | |
* This <b>MUST</b> be called in your onDisable(). | |
*/ | |
public abstract void close(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment