起因
前些天在某个群跟群友聊天时,偶然听说了当一个符合 authlib-injector 规范的以非 ASCII 玩家 ID 的玩家连接 BungeeCord 时,BungeeCord 会以玩家 ID 字符不被允许为由禁止玩家加入服务器。这个问题令我很感兴趣,思考了一番以后,决定为 authlib-injector 贡献一个功能来解决这个问题。
定位问题
通过交流测试得知,当这样的玩家加入这样的服务器时,客户端会以“Username contains invalid characters.”提示将玩家断开连接,因此我们前往 BungeeCord 的 GitHub 仓库中检索该字符串,并在 proxy/src/main/resources/messages.properties 处找到了其对应的本地化键 “name_invalid”;接着检索该本地化键,最终在 proxy/src/main/java/net/md_5/bungee/connection/InitialHandler.java 处找到了核心逻辑:
...
if ( !AllowedCharacters.isValidName( loginRequest.getData(), onlineMode ) )
{
disconnect( bungee.getTranslation( "name_invalid" ) );
return;
}
...
而 AllowedCharacter 类代码如下:
package net.md_5.bungee.util;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AllowedCharacters
{
public static boolean isChatAllowedCharacter(char character)
{
// Section symbols, control sequences, and deletes are not allowed
return character != '\u00A7' && character >= ' ' && character != 127;
}
private static boolean isNameAllowedCharacter(char c, boolean onlineMode)
{
if ( onlineMode )
{
return ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) || ( c >= 'A' && c <= 'Z' ) || c == '_' || c == '.' || c == '-';
} else
{
// Don't allow spaces, Yaml config doesn't support them
return isChatAllowedCharacter( c ) && c != ' ';
}
}
public static boolean isValidName(String name, boolean onlineMode)
{
for ( int index = 0, len = name.length(); index < len; index++ )
{
if ( !isNameAllowedCharacter( name.charAt( index ), onlineMode ) )
{
return false;
}
}
return true;
}
}
这意味着:
- 当玩家是离线验证模式时,玩家 ID 不能为分节符,控制符和删除符
- 当玩家是正版验证模式时,玩家 ID 不能匹配 [A-Za-z0-9_.-]
因为 authlib-injector 玩家实际上会被服务端识别为正版验证模式玩家,又因为非 ASCII 的 ID 不匹配这个要求,因此 BungeeCord 会直接拒绝这些玩家的连接。
根据以上分析,我决定通过修改字节码,让正版验证模式的玩家使用和盗版模式相同的 ID 匹配方式,这就意味着,应该将:
private static boolean isNameAllowedCharacter(char c, boolean onlineMode)
{
if ( onlineMode )
{
return ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) || ( c >= 'A' && c <= 'Z' ) || c '_' || c '.' || c == '-';
} else
{
// Don't allow spaces, Yaml config doesn't support them
return isChatAllowedCharacter( c ) && c != ' ';
}
}
直接修改为
private static boolean isNameAllowedCharacter(char c, boolean onlineMode)
{
return isChatAllowedCharacter( c ) && c != ' ';
}
定位了问题以及确定了目标后,我们便可以着手修改字节码了:
字节码修改
通过使用 recaf 反编译 BungeeCord 的 jar,我们得到了 isNameAllowedCharacter
方法的字节码
DEFINE PRIVATE STATIC isNameAllowedCharacter(C c, Z onlineMode)Z
A:
LINE A 18
ILOAD onlineMode
IFEQ I
B:
LINE B 20
ILOAD c
BIPUSH 97
IF_ICMPLT C
ILOAD c
BIPUSH 122
IF_ICMPLE F
C:
ILOAD c
BIPUSH 48
IF_ICMPLT D
ILOAD c
BIPUSH 57
IF_ICMPLE F
D:
ILOAD c
BIPUSH 65
IF_ICMPLT E
ILOAD c
BIPUSH 90
IF_ICMPLE F
E:
ILOAD c
BIPUSH 95
IF_ICMPEQ F
ILOAD c
BIPUSH 46
IF_ICMPEQ F
ILOAD c
BIPUSH 45
IF_ICMPNE G
F:
ICONST_1
GOTO H
G:
ICONST_0
H:
IRETURN
I:
LINE I 24
ILOAD c
INVOKESTATIC net/md_5/bungee/util/AllowedCharacters.isChatAllowedCharacter(C)Z
IFEQ J
ILOAD c
BIPUSH 32
IF_ICMPEQ J
ICONST_1
GOTO K
J:
ICONST_0
K:
IRETURN
L:
按照我们的想法,修改为:
A:
LINE A 11
ILOAD c
INVOKESTATIC net/md_5/bungee/util/AllowedCharacters.isChatAllowedCharacter(C)Z
IFEQ B
ILOAD c
BIPUSH 32
IF_ICMPEQ B
ICONST_1
GOTO C
B:
ICONST_0
C:
IRETURN
D:
这样,我们便可使用 ASM,将新的字节码注入到 BungeeCord 中
使用 ASM 替换字节码
authlib-injector 项目本身作为一个 “hacker”,自然也是通过 ASM 替换关键代码,因此,我们可以使用 authlib-injector 项目内置的 ASM 来达到我们的效果。因此,我创建了 java/moe/yushi/authlibinjector/transform/support/BungeeCordTransformer.java 类,并实现了 TransformUnit
接口:
package moe.yushi.authlibinjector.transform.support;
import moe.yushi.authlibinjector.transform.TransformContext;
import moe.yushi.authlibinjector.transform.TransformUnit;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import java.util.Optional;
import static org.objectweb.asm.Opcodes.*;
/**
* Support for BungeeCord and downstream branches
* <p>
* BungeeCord limited the player name character in <https://github.com/SpigotMC/BungeeCord/blob/c7b0c3cd48c9929c6ba41ff333727adba89b4e07/proxy/src/main/java/net/md_5/bungee/util/AllowedCharacters.java#L28>
* caused all non-ASCII characters profile can not join the server.
* This class is used to replace the original method to allow all characters in offline mode.
*/
public class BungeeCordTransformer implements TransformUnit {
@Override
public Optional<ClassVisitor> transform(ClassLoader classLoader, String className, ClassVisitor writer, TransformContext context) {
if ("net.md_5.bungee.util.AllowedCharacters".equals(className)) {
return Optional.of(new ClassVisitor(ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
// private static boolean isNameAllowedCharacter(char c, boolean onlineMode)
if ("isNameAllowedCharacter".equals(name) && "(CZ)Z".equals(descriptor)) {
return new MethodVisitor(ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) {
@Override
public void visitCode() {
// return isChatAllowedCharacter( c ) && c != ' ';
super.visitCode();
// A:
Label a = new Label();
super.visitLabel(a);
super.visitFrame(F_SAME, 0, null, 0, null);
// LINE A 11
super.visitLineNumber(11, a);
// ILOAD c
super.visitVarInsn(ILOAD, 0);
// INVOKESTATIC net/md_5/bungee/util/AllowedCharacters.isChatAllowedCharacter(C)Z
super.visitMethodInsn(INVOKESTATIC, "net/md_5/bungee/util/AllowedCharacters", "isChatAllowedCharacter", "(C)Z", false);
// IFEQ B
Label falseLabel = new Label();
super.visitJumpInsn(IFEQ, falseLabel);
// ILOAD c
super.visitVarInsn(ILOAD, 0);
// BIPUSH 32
super.visitIntInsn(BIPUSH, 32);
// IF_ICMPEQ B
super.visitJumpInsn(IF_ICMPEQ, falseLabel);
// ICONST_1
super.visitInsn(ICONST_1);
// GOTO C
Label returnLabel = new Label();
super.visitJumpInsn(GOTO, returnLabel);
// B:
super.visitLabel(falseLabel);
super.visitFrame(F_SAME, 0, null, 0, null);
// ICONST_0
super.visitInsn(ICONST_0);
// C:
super.visitLabel(returnLabel);
super.visitFrame(F_SAME1, 0, null, 1, new Object[]{INTEGER});
// IRETURN
super.visitInsn(IRETURN);
// D:
Label d = new Label();
super.visitLabel(d);
super.visitFrame(F_SAME, 0, null, 0, null);
//super.visitMaxs(2, 2);
super.visitEnd();
context.markModified();
}
};
}
return super.visitMethod(access, name, descriptor, signature, exceptions);
}
});
}
return Optional.empty();
}
@Override
public String toString() {
return "BungeeCord Support";
}
}
首先,我们按照函数签名(也就是isNameAllowedCharacter(C c, Z onlineMode)Z
)找到了我们想要替换的方法,然后重写 visitCode
方法,调用 父类的 visitXXX
方法写入字节码。
然后,我们需要根据 JVM 的要求,通过调用 visitFrame
方法,为所有直接跳转的 Label 标记堆栈映射帧(stack map frames),记录跳转时作用域的局部变量和操作数栈信息。
最后,为无关方法直接调用父类方法,即不做处理。
这样,我们便成功的绕过了 BungeeCord 对正版验证玩家的字符限制,解决了这个问题。
后记
因为 ASM 这个玩意挺底层的,而且由于初来乍到,因此中途进行了多次试错和调试。结果好巧不巧,正当我调试完毕,让这些功能正常运行了的时候,authlib-injector 的原作者 yushijinhun 也正好发布了相同的修正(因为他也在群里看到了这些讨论,于是就迅速修复了),然后我看了一下他的写法,瞬间感觉我瞎干了两天:
/*
* Copyright (C) 2022 Haowei Wen <[email protected]> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package moe.yushi.authlibinjector.transform.support;
import static org.objectweb.asm.Opcodes.ASM9;
import static org.objectweb.asm.Opcodes.ISTORE;
import java.util.Optional;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import moe.yushi.authlibinjector.transform.TransformContext;
import moe.yushi.authlibinjector.transform.TransformUnit;
/**
* Hacks BungeeCord to allow non-ASCII characters in username.
*
* Since <https://github.com/SpigotMC/BungeeCord/commit/3008d7ef2f50de7e3d38e76717df72dac7fe0da3>,
* BungeeCord allows only ASCII characters in username when online-mode is on.
*/
public class BungeeCordAllowedCharactersTransformer implements TransformUnit {
@Override
public Optional<ClassVisitor> transform(ClassLoader classLoader, String className, ClassVisitor writer, TransformContext context) {
if ("net.md_5.bungee.util.AllowedCharacters".equals(className)) {
return Optional.of(new ClassVisitor(ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
if ("isValidName".equals(name) && "(Ljava/lang/String;Z)Z".equals(descriptor)) {
return new MethodVisitor(ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) {
@Override
public void visitCode() {
super.visitCode();
super.visitLdcInsn(0);
super.visitVarInsn(ISTORE, 1);
context.markModified();
}
};
}
return super.visitMethod(access, name, descriptor, signature, exceptions);
}
});
}
return Optional.empty();
}
@Override
public String toString() {
return "BungeeCord Allowed Characters Transformer";
}
}
关键代码只有两行:
super.visitLdcInsn(0);
super.visitVarInsn(ISTORE, 1);
这两行代码是这么运作的:
1. 首先,将数字 0(同时也是 false)读入操作数栈
2. 将这个数字取出,然后存到局部变量下标为 1 的变量中
我刚开始还没整明白怎么回事,问了一下才恍然大悟:
看来打铁还需自身硬啊(叹)...
(完)