ASM 那点事 —— 让 BungeeCord 允许 authlib-injector 中非 ASCII 的玩家 ID 连接

2022-10-27 09:59:24 浏览数 (2)

本文最后更新于 222 天前,其中的信息可能已经有所发展或是发生改变。

起因

前些天在某个群跟群友聊天时,偶然听说了当一个符合 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 处找到了核心逻辑:

代码语言:javascript复制
...
if ( !AllowedCharacters.isValidName( loginRequest.getData(), onlineMode ) )
        {
            disconnect( bungee.getTranslation( "name_invalid" ) );
            return;
        }
...

而 AllowedCharacter 类代码如下:

代码语言:javascript复制
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 匹配方式,这就意味着,应该将:

代码语言:javascript复制
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 != ' ';
        }
    }

直接修改为

代码语言:javascript复制
private static boolean isNameAllowedCharacter(char c, boolean onlineMode)
    {
        return isChatAllowedCharacter( c ) && c != ' ';
    }

定位了问题以及确定了目标后,我们便可以着手修改字节码了:

字节码修改

通过使用 recaf 反编译 BungeeCord 的 jar,我们得到了 isNameAllowedCharacter 方法的字节码

代码语言:javascript复制
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:

按照我们的想法,修改为:

代码语言:javascript复制
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 接口:

代码语言:javascript复制
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 也正好发布了相同的修正(因为他也在群里看到了这些讨论,于是就迅速修复了),然后我看了一下他的写法,瞬间感觉我瞎干了两天:

代码语言:javascript复制
/*
 * Copyright (C) 2022  Haowei Wen <yushijinhun@gmail.com> 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";
    }
}

关键代码只有两行:

代码语言:javascript复制
super.visitLdcInsn(0);
super.visitVarInsn(ISTORE, 1);

这两行代码是这么运作的: 1. 首先,将数字 0(同时也是 false)读入操作数栈 2. 将这个数字取出,然后存到局部变量下标为 1 的变量中

我刚开始还没整明白怎么回事,问了一下才恍然大悟:

看来打铁还需自身硬啊(叹)…

(完)

0 人点赞