2023 D^3CTF MISC d3craft

第一次给这么大型的比赛出题, 有点慌qaq. 本题仅有 3 支队伍解出, 比预期少一点. 可能是大佬们在看其他更有挑战性的题目, 对游戏不感兴趣吧. 其中来自满分冰美式的师傅以非预期的解法薄纱此题. 下面我会详细分析这题的预期解法, 以及非预期的做法, 欢迎各位对 Minecraft Hack 感兴趣的师傅一起交流!

远程是一个 Minecraft PaperMC 服务器, 1.19.4 版本. 链接进去, 提示走到信标处挥手 (左键) 即可获得 flag. 尝试移动, 发现被检测到了, 然后踢出服务器.

附件给出了服务端 jar 文件, 配置文件, 世界, 以及所安装的插件, 还有一个 paper 的 patch 文件, 供本地调试.

插件没有混淆, 逆向直接得出源码:

  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
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
package org.d3ctf.d3craft;

import io.papermc.paper.entity.LookAnchor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.*;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;

import java.util.Random;
import static java.lang.Math.sqrt;

public final class Main extends JavaPlugin implements Listener {

    public static final double RADIUS = 16;
    public static final double HORIZON = -60;
    private static final Random random = new Random();
    private static final String flag = "flag{this_is_a_test_flag}";

    private Player currentPlayer = null;

    @Override
    public void onEnable() {
        // Plugin startup logic
        getServer().getPluginManager().registerEvents(this, this);
    }

    @Override
    public void onDisable() {
        // Plugin shutdown logic
    }

    @Contract("_ -> new")
    private @NotNull Location randomPosition(World world) {
        double x = random.nextDouble(16);
        double z = sqrt(RADIUS * RADIUS - x * x);
        boolean sign = random.nextBoolean();
        x *= sign ? 1 : -1;
        sign = random.nextBoolean();
        z *= sign ? 1 : -1;
        return new Location(world, x, HORIZON, z);
    }

    public void sendHello(@NotNull Player player) {
        player.sendMessage("Welcome to");
        player.sendMessage(Component.text(" ____ ", NamedTextColor.RED)
                .append(Component.text("___  ", TextColor.color(0xFFA500)))
                .append(Component.text("___ ", NamedTextColor.YELLOW))
                .append(Component.text("____   ", NamedTextColor.GREEN))
                .append(Component.text("_ _   ", TextColor.color(0x00FFFF)))
                .append(Component.text("___ ", NamedTextColor.BLUE))
                .append(Component.text("___ ", NamedTextColor.DARK_PURPLE))
        );
        player.sendMessage(Component.text("(   _  ", NamedTextColor.RED)
                .append(Component.text("(__ )", TextColor.color(0xFFA500)))
                .append(Component.text("/ __", NamedTextColor.YELLOW))
                .append(Component.text("|  __ \\ ", NamedTextColor.GREEN))
                .append(Component.text("/__\\ ", TextColor.color(0x00FFFF)))
                .append(Component.text("(___|", NamedTextColor.BLUE))
                .append(Component.text("_   _)", NamedTextColor.DARK_PURPLE))
        );
        player.sendMessage(Component.text(" ) (_)  ", NamedTextColor.RED)
                .append(Component.text("|_  ", TextColor.color(0xFFA500)))
                .append(Component.text("( (__ ", NamedTextColor.YELLOW))
                .append(Component.text(")     /", NamedTextColor.GREEN))
                .append(Component.text("/(__)\\ ", TextColor.color(0x00FFFF)))
                .append(Component.text(")__)  ", NamedTextColor.BLUE))
                .append(Component.text(") (", NamedTextColor.DARK_PURPLE))
        );
        player.sendMessage(Component.text("(____", NamedTextColor.RED)
                .append(Component.text("(___/", TextColor.color(0xFFA500)))
                .append(Component.text("\\___", NamedTextColor.YELLOW))
                .append(Component.text("|_)\\_", NamedTextColor.GREEN))
                .append(Component.text("|__) (__", TextColor.color(0x00FFFF)))
                .append(Component.text("|__)  ", NamedTextColor.BLUE))
                .append(Component.text("(__)", NamedTextColor.DARK_PURPLE))
        );
        player.sendMessage(Component.text("Did you see the ")
                .append(Component.text("light", NamedTextColor.LIGHT_PURPLE))
                .append(Component.text(" over there?"))
        );
        player.sendMessage(Component.text("Go there and ")
                .append(Component.text("wave your hand.").decoration(TextDecoration.ITALIC, true))
        );
        player.sendMessage(Component.text("I'll give you the ")
                .append(Component.text("flag").decoration(TextDecoration.BOLD, true))
        );
    }

    void preparePlayerLocation(@NotNull Player player) {
        Location location = randomPosition(player.getWorld());
        player.teleport(location);
        getLogger().info("set player " + player.getName() + " location to " + location);
        player.lookAt(0.5, HORIZON, 0.5, LookAnchor.FEET);
        getLogger().info("set player " + player.getName() + " look at flag");
    }

    boolean checkLocation(@NotNull Location location) {
        int x = location.getBlockX();
        int z = location.getBlockZ();
        return x == 0 && z == 0;
    }

    @EventHandler
    public void onPlayerJoin(@NotNull PlayerJoinEvent event) {
        Player player = event.getPlayer();
        sendHello(player);
        preparePlayerLocation(player);
    }

    @EventHandler
    public void onPlayerMove(@NotNull PlayerMoveEvent event) {
        Player player = event.getPlayer();
        if (player == currentPlayer)
            player.kick(Component.text("Hold Still, HACKER!\nDon't MOVE"));
        else if (currentPlayer == null)
            currentPlayer = player;
        else
            player.kick(Component.text("HACKER!"));
    }

    @EventHandler
    public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
        currentPlayer = null;
    }

    @EventHandler
    public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
        Player player = event.getPlayer();
        if (event.getAction() == Action.LEFT_CLICK_AIR && checkLocation(player.getLocation())) {
            getLogger().info("player " + player.getName() + " get flag!");
            player.sendMessage(flag);
        }
    }
}

即使没有 Minecraft 服务器插件开发经验的师傅, 结合进入游戏的提示, 看这个函数名应该也能猜到这个插件的功能.

  1. 监听玩家加入世界 (onPlayerJoin) 事件, 随机生成离 (0, -60, 0) 距离 16 的点, 将玩家移动过去;
  2. 监听玩家移动 (onPlayerMove) 事件, 将玩家踢出游戏;
  3. 监听玩家交互 (onPlayerInteract) 事件, 如果是点击左键, 并且玩家位置在 (0, x, 0), 则给玩家 flag.
currentPlayer 变量
玩家加入游戏后, 插件使用 player.teleport() 去设置玩家位置, 这里会触发 onPlayerMove 事件, 所以设置了 currentPlayer 变量来使得这一次不被踢出游戏. 可能有些师傅被误导了, 认为是要进入游戏一步移动过去, 非常抱歉.

可以将插件删除进入世界, 确认 (0, x, 0) 处就是信标位置.

看上去不可能移动到信标. 不过如果尝试过潜行移动, 可以发现是能够移动很小一段距离而不被踢出的.

先来思考一下为什么会发生这样的事情. 既然插件写的是监听事件, 那么说明玩家移动事件没有被监听到. 这里有两种可能, 一种可能是客户端觉得这移动太小了, 没必要将数据发送给服务端; 而另一种可能是数据发送给了服务端, 但是服务端做了某些处理, 没有将事件触发. 题目附件中给出了一个 Paper 的 patch 文件, 所以从服务端入手.

游戏原理

客户端与服务端的通信原理就是双方不断接收发送数据包, 数据包格式在 这里 可以查阅. 这题需要考虑的是和移动相关的数据包 (Set Player Position). 不用太深入了解, 仅需要知道数据包含有移动的目的位置即可. 如果我们可以修改发送的数据包 (比如通过代理), 或者自己发送一个过去, 那么就可以做到一些正常玩游戏无法实现的操作.

实际上客户端每个 tick 都会发送一个玩家位置数据包, 即使玩家没有操作.

本题使用的服务端是 Paper. 它是一个第三方开源 Minecraft 服务端, 其原理是逆向官方版本的 server, 通过打 patch 添加更多的功能和优化等, 重新编译得到一个服务端文件. 详细内容可以阅读官方文档 CONTRIBUTING.md.

在官网的 下载页面 上能找到版本对应的 commit 号, 492 是 497b919. (不过本题和版本其实无关, 出题时 492 是最新版本罢了). clone 仓库, 切换到 commit 497b919, 根据 文档 的指引生成源码 (./gradlew applyPatches), 然后把 d3craft 的 patch 放入 ./patches/server 中, 重新生成源码 (./gradlew rebuildPatches). ./Paper-API, ./Paper-MojangAPI, ./Paper-Server 就是服务端源码了. 其中打了 d3craft patch 的文件是 ./Paper-Server/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java.

patch 的内容为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: WingsZeng <wings.xiangyi.zeng@gmail.com>
Date: Thu, 6 Apr 2023 11:22:18 +0800
Subject: [PATCH] d3craft-patch


diff --git a/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java b/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java
index 177aac1ab10189bb5a52217e86ba5c8a535b4197..132494836fbb98f6676c3111c95c36b6826ccf0d 100644
--- a/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java
+++ b/src/main/java/net/minecraft/server/network/ServerGamePacketListenerImpl.java
@@ -1478,7 +1478,10 @@ public class ServerGamePacketListenerImpl implements ServerPlayerConnection, Tic
                                 if (d11 - d10 > Math.max(f2, Math.pow((double) (org.spigotmc.SpigotConfig.movedTooQuicklyMultiplier * (float) i * speed), 2)) && !this.isSingleplayerOwner()) {
                                 // CraftBukkit end
                                     ServerGamePacketListenerImpl.LOGGER.warn("{} moved too quickly! {},{},{}", new Object[]{this.player.getName().getString(), d7, d8, d9});
-                                    this.teleport(this.player.getX(), this.player.getY(), this.player.getZ(), this.player.getYRot(), this.player.getXRot());
+                                    // d3craft start
+                                    // this.teleport(this.player.getX(), this.player.getY(), this.player.getZ(), this.player.getYRot(), this.player.getXRot());
+                                    this.internalTeleport(this.lastPosX, this.lastPosY, this.lastPosZ, this.lastYaw, this.lastPitch, Collections.emptySet());
+                                    // d3craft end
                                     return;
                                 }
                             }

即将 handleMovePlayer 函数中的 this.teleport(this.player.getX(), this.player.getY(), this.player.getZ(), this.player.getYRot(), this.player.getXRot()) 一句换成了 this.internalTeleport(this.lastPosX, this.lastPosY, this.lastPosZ, this.lastYaw, this.lastPitch, Collections.emptySet()).

审计一下两个函数, 其实是一样的功能, 唯一不同点在参数不一样. 不过这貌似不是移动一小段没有触发事件的原因, 但是从这里可以发现, 玩家位置貌似是有两个记录. 一个是 playergetX(), 另一个是 this.lastPosX. player 的记录在 Player 类中, 而 lastPosX 在当前类 ServerGamePacketListenerImpl 中. 为什么要这样做呢?

handleMovePlayer 函数中寻找 PlayerMove 事件, 可以发现如下代码 (1576 行):

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
      // CraftBukkit start - fire PlayerMoveEvent
      // Rest to old location first
      this.player.absMoveTo(prevX, prevY, prevZ, prevYaw, prevPitch);

      Player player = this.getCraftPlayer();
      Location from = new Location(player.getWorld(), this.lastPosX, this.lastPosY, this.lastPosZ, this.lastYaw, this.lastPitch); // Get the Players previous Event location.
      Location to = player.getLocation().clone(); // Start off the To location as the Players current location.

      // If the packet contains movement information then we update the To location with the correct XYZ.
      if (packet.hasPos) {
          to.setX(packet.x);
          to.setY(packet.y);
          to.setZ(packet.z);
      }

      // If the packet contains look information then we update the To location with the correct Yaw & Pitch.
      if (packet.hasRot) {
          to.setYaw(packet.yRot);
          to.setPitch(packet.xRot);
      }

      // Prevent 40 event-calls for less than a single pixel of movement >.>
      double delta = Math.pow(this.lastPosX - to.getX(), 2) + Math.pow(this.lastPosY - to.getY(), 2) + Math.pow(this.lastPosZ - to.getZ(), 2);
      float deltaAngle = Math.abs(this.lastYaw - to.getYaw()) + Math.abs(this.lastPitch - to.getPitch());

      if ((delta > 1f / 256 || deltaAngle > 10f) && !this.player.isImmobile()) {
          this.lastPosX = to.getX();
          this.lastPosY = to.getY();
          this.lastPosZ = to.getZ();
          this.lastYaw = to.getYaw();
          this.lastPitch = to.getPitch();

          // Skip the first time we do this
          if (from.getX() != Double.MAX_VALUE) {
              Location oldTo = to.clone();
              PlayerMoveEvent event = new PlayerMoveEvent(player, from, to);
              this.cserver.getPluginManager().callEvent(event);

              // If the event is cancelled we move the player back to their old location.
              if (event.isCancelled()) {
                  this.teleport(from);
                  return;
              }

              // If a Plugin has changed the To destination then we teleport the Player
              // there to avoid any 'Moved wrongly' or 'Moved too quickly' errors.
              // We only do this if the Event was not cancelled.
              if (!oldTo.equals(event.getTo()) && !event.isCancelled()) {
                  this.player.getBukkitEntity().teleport(event.getTo(), PlayerTeleportEvent.TeleportCause.PLUGIN);
                  return;
              }

              // Check to see if the Players Location has some how changed during the call of the event.
              // This can happen due to a plugin teleporting the player instead of using .setTo()
              if (!from.equals(this.getCraftPlayer().getLocation()) && this.justTeleported) {
                  this.justTeleported = false;
                  return;
              }
          }
      }
      this.player.absMoveTo(d0, d1, d2, f, f1); // Copied from above
      // CraftBukkit end

这段代码功能如下:

  1. 将玩家移动回 prev 位置 (prevX 等, 在 handleMovePlayer 刚开始时 PreX = this.player.getX();, 1411 行) (先前有过修改 player 的位置, 这一段是 CraftBukkit 加上去的 patch, 所以又给移动回去了)
  2. 判断接受到的数据包位置 to 和 lastPos 位置 (from) 的距离变化 (的平方) (delta) 以及视角变化 (deltaAngle), 如果位置 (或视角) 变化超过了阈值 (delta > 1f / 256 || deltaAngle > 10f), 则更新 lastPos 为 to, 并且触发 PlayerMoveEvent; 如果位置 (和视角) 变化不大, 则不更新 lastPos, 也不触发 PlayerMoveEvent.
  3. 移动到 (d0, d1, d2, f, f1). 简单看一下代码可以知道这是玩家需要移动的位置.

这就是为什么能够移动很小一段但不触发事件的原因. 不过由于没有更新 lastPos, 所以如果多次移动很小的距离会 “累加” 这个差值, 直到大于阈值, 触发事件. (当然在不偏离阈值的小范围内移动是随意的)

可以尝试修改阈值, 重新编译测试一下.

接下来思考如何利用. 还有 patch 了的地方没有仔细看. 找到 teleportinternalTeleport (1692 行):

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
    // CraftBukkit start - Delegate to teleport(Location)
    public void teleport(double x, double y, double z, float yaw, float pitch) {
        this.teleport(x, y, z, yaw, pitch, PlayerTeleportEvent.TeleportCause.UNKNOWN);
    }

    public void teleport(double d0, double d1, double d2, float f, float f1, PlayerTeleportEvent.TeleportCause cause) {
        this.teleport(d0, d1, d2, f, f1, Collections.emptySet(), cause);
    }

    // ...

    public boolean teleport(double d0, double d1, double d2, float f, float f1, Set<RelativeMovement> set, PlayerTeleportEvent.TeleportCause cause) { // CraftBukkit - Return event status
        Player player = this.getCraftPlayer();
        Location from = player.getLocation();

        double x = d0;
        double y = d1;
        double z = d2;
        float yaw = f;
        float pitch = f1;

        Location to = new Location(this.getCraftPlayer().getWorld(), x, y, z, yaw, pitch);
        // SPIGOT-5171: Triggered on join
        if (from.equals(to)) {
            this.internalTeleport(d0, d1, d2, f, f1, set);
            return false; // CraftBukkit - Return event status
        }

        // Paper start - Teleport API
        Set<io.papermc.paper.entity.TeleportFlag.Relative> relativeFlags = java.util.EnumSet.noneOf(io.papermc.paper.entity.TeleportFlag.Relative.class);
        for (RelativeMovement relativeArgument : set) {
            relativeFlags.add(org.bukkit.craftbukkit.entity.CraftPlayer.toApiRelativeFlag(relativeArgument));
        }
        PlayerTeleportEvent event = new PlayerTeleportEvent(player, from.clone(), to.clone(), cause, java.util.Set.copyOf(relativeFlags));
        // Paper end
        this.cserver.getPluginManager().callEvent(event);

        if (event.isCancelled() || !to.equals(event.getTo())) {
            //set.clear(); // Can't relative teleport // Paper - Teleport API: Now you can!
            to = event.isCancelled() ? event.getFrom() : event.getTo();
            d0 = to.getX();
            d1 = to.getY();
            d2 = to.getZ();
            f = to.getYaw();
            f1 = to.getPitch();
        }

        this.internalTeleport(d0, d1, d2, f, f1, set);
        return event.isCancelled(); // CraftBukkit - Return event status
    }

    // ...

    public void internalTeleport(double d0, double d1, double d2, float f, float f1, Set<RelativeMovement> set) { // Paper
        org.spigotmc.AsyncCatcher.catchOp("teleport"); // Paper
        // Paper start
        if (player.isRemoved()) {
            LOGGER.info("Attempt to teleport removed player {} restricted", player.getScoreboardName());
            if (server.isDebugging()) io.papermc.paper.util.TraceUtil.dumpTraceForThread("Attempt to teleport removed player");
            return;
        }
        // Paper end
        // CraftBukkit start
        if (Float.isNaN(f)) {
            f = 0;
        }
        if (Float.isNaN(f1)) {
            f1 = 0;
        }

        this.justTeleported = true;
        // CraftBukkit end
        double d3 = set.contains(RelativeMovement.X) ? this.player.getX() : 0.0D;
        double d4 = set.contains(RelativeMovement.Y) ? this.player.getY() : 0.0D;
        double d5 = set.contains(RelativeMovement.Z) ? this.player.getZ() : 0.0D;
        float f2 = set.contains(RelativeMovement.Y_ROT) ? this.player.getYRot() : 0.0F;
        float f3 = set.contains(RelativeMovement.X_ROT) ? this.player.getXRot() : 0.0F;

        this.awaitingPositionFromClient = new Vec3(d0, d1, d2);
        if (++this.awaitingTeleport == Integer.MAX_VALUE) {
            this.awaitingTeleport = 0;
        }

        // CraftBukkit start - update last location
        this.lastPosX = this.awaitingPositionFromClient.x;
        this.lastPosY = this.awaitingPositionFromClient.y;
        this.lastPosZ = this.awaitingPositionFromClient.z;
        this.lastYaw = f;
        this.lastPitch = f1;
        // CraftBukkit end

        this.awaitingTeleportTime = this.tickCount;
        this.player.moveTo(d0, d1, d2, f, f1); // Paper - use proper moveTo for teleportation
        this.player.connection.send(new ClientboundPlayerPositionPacket(d0 - d3, d1 - d4, d2 - d5, f - f2, f1 - f3, set, this.awaitingTeleport));
    }

teleport 是对 internalTeleport 的封装, teleport 会触发玩家传送事件, 而 internalTeleport 不会. 在 internalTeleport 中可以发现 lastPos 被设置成了传入的参数!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

        this.awaitingPositionFromClient = new Vec3(d0, d1, d2);
        if (++this.awaitingTeleport == Integer.MAX_VALUE) {
            this.awaitingTeleport = 0;
        }

        // CraftBukkit start - update last location
        this.lastPosX = this.awaitingPositionFromClient.x;
        this.lastPosY = this.awaitingPositionFromClient.y;
        this.lastPosZ = this.awaitingPositionFromClient.z;
        this.lastYaw = f;
        this.lastPitch = f1;

所以 lastPos 是会被 teleport 或者 internalTeleport 给修改掉的! 这是否可以绕过 PlayerMoveEvent, 在不触发事件的情况下移动位置呢?

被打上 patch 的这个部分功能是检查玩家是否移动过快 (这点在协议文档里也有说明), 如果是, 则 teleport (internalTeleport) 回去.

先看原来的代码, 传入的是 this.player.getX(), this.player.getY(), this.player.getZ(), 所以在最后 lastPos 会被设置成这个值. 所以, 绕过的方法如下:

  1. 先移动一小步, 不触发 PlayerMoveEvent, 不修改 lastPos 但是修改 player location.
  2. 移动非常远 (修改移动数据包中的位置), 进入这个 if 触发 teleport, 进而修改 lastPos 为 player location.
  3. 重复, 就能做到不触发 PlayerMoveEvent 而移动玩家位置!
Hack Minecraft
不知道师傅们做题的时候有没有搜到 LiveOverflow 的视频 WorldGuard Bypass. 这个视频简单介绍了一下如何绕过 PlayerMoveEvent 而移动, 用的就是这个方法. 这也是出题人的灵感来源. 正如视频中所说的那样, 这个利用点早在 16 年就存在了, 而且 1.9 版本后通用, 所以有可能师傅们也能搜到其他相关的内容. 有一个名为 packetfly 的 hack 就是利用这个地方的漏洞重置位置, 以实现作弊飞行.

非常巧妙的 hack, 不过很可惜被 patch 掉了.

幸运的是, 代码里有很多检查非法移动的部分. 一共能够找到 4 个类似的 teleport 回去:

1438 行:

1
2
3
4
5
    if (this.player.isSleeping()) {
        if (d11 > 1.0D) {
            this.teleport(this.player.getX(), this.player.getY(), this.player.getZ(), f, f1);
        }
    // ...

判断玩家是否在睡觉. 世界里没有床, 无法达到利用条件.

1468 行:

1
2
3
4
5
    // Paper start - Prevent moving into unloaded chunks
    if (player.level.paperConfig().chunks.preventMovingIntoUnloadedChunks && (this.player.getX() != toX || this.player.getZ() != toZ) && !worldserver.areChunksLoadedForMove(this.player.getBoundingBox().expandTowards(new Vec3(toX, toY, toZ).subtract(this.player.position())))) {
        this.internalTeleport(this.player.getX(), this.player.getY(), this.player.getZ(), this.player.getYRot(), this.player.getXRot(), Collections.emptySet());
        return;
    }

判断是否进入没有加载的区块 (Chunk). 但是配置文件没有把这一项检查打开, 无法达到利用条件.

1521 行:

 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
    if (this.player.isOnGround() && !packet.isOnGround() && flag) {
        // Paper start - Add player jump event
        Player player = this.getCraftPlayer();
        Location from = new Location(player.getWorld(), lastPosX, lastPosY, lastPosZ, lastYaw, lastPitch); // Get the Players previous Event location.
        Location to = player.getLocation().clone(); // Start off the To location as the Players current location.

        // If the packet contains movement information then we update the To location with the correct XYZ.
        if (packet.hasPos) {
            to.setX(packet.x);
            to.setY(packet.y);
            to.setZ(packet.z);
        }

        // If the packet contains look information then we update the To location with the correct Yaw & Pitch.
        if (packet.hasRot) {
            to.setYaw(packet.yRot);
            to.setPitch(packet.xRot);
        }

        com.destroystokyo.paper.event.player.PlayerJumpEvent event = new com.destroystokyo.paper.event.player.PlayerJumpEvent(player, from, to);

        if (event.callEvent()) {
            this.player.jumpFromGround();
        } else {
            from = event.getFrom();
            this.internalTeleport(from.getX(), from.getY(), from.getZ(), from.getYaw(), from.getPitch(), Collections.emptySet());
            return;
        }
        // Paper end
    }

PlayerJumpEvent 被 (插件) 取消. 插件没有干这个事, 无法达到利用条件.

1567 行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    if (!this.player.isChangingDimension() && d11 > org.spigotmc.SpigotConfig.movedWronglyThreshold && !this.player.isSleeping() && !this.player.gameMode.isCreative() && this.player.gameMode.getGameModeForPlayer() != GameType.SPECTATOR) { // Spigot
        flag2 = true; // Paper - diff on change, this should be moved wrongly
        ServerGamePacketListenerImpl.LOGGER.warn("{} moved wrongly!", this.player.getName().getString());
    }

    this.player.absMoveTo(d0, d1, d2, f, f1);
    // Paper start - optimise out extra getCubes
    // Original for reference:
    // boolean teleportBack = flag2 && worldserver.getCubes(this.player, axisalignedbb) || (didCollide && this.a((IWorldReader) worldserver, axisalignedbb));
    boolean teleportBack = flag2; // violating this is always a fail
    if (!this.player.noPhysics && !this.player.isSleeping() && !teleportBack) {
        AABB newBox = this.player.getBoundingBox();
        if (didCollide || !axisalignedbb.equals(newBox)) {
            // note: only call after setLocation, or else getBoundingBox is wrong
            teleportBack = this.hasNewCollision(worldserver, this.player, axisalignedbb, newBox);
        } // else: no collision at all detected, why do we care?
    }
    if (!this.player.noPhysics && !this.player.isSleeping() && teleportBack) { // Paper end - optimise out extra getCubes
        this.internalTeleport(d3, d4, d5, f, f1, Collections.emptySet()); // CraftBukkit - SPIGOT-1807: Don't call teleport event, when the client thinks the player is falling, because the chunks are not loaded on the client yet.
        this.player.doCheckFallDamage(this.player.getY() - d6, packet.isOnGround());
    }

函数入口不远处可以看到 d3, d4 等就是 player.getX(), player.getY()

两个利用点, 一个是 moved wrongly, 一个是碰撞箱相关的错误. 看起来可以从这里入手.

(出这个题一开始是想找 moved wrongly, 一番搜寻后未果, 自己也没尝试出来. 如果有师傅了解如何触发这个可以交流交流qaq.)

碰撞箱相关的错误相对容易实现, 预期解也是从这里入手. (碰撞箱这一块出题人也没有理解得很清楚, 如有错误欢迎指出)

碰撞箱这里也有两个利用点, didCollide!axisalignedbb.equals(newBox), axisalignedbb 是移动前的碰撞箱, newBox 是移动后的碰撞箱. didCollide 定义如下:

1
2
    this.player.move(MoverType.PLAYER, new Vec3(d7, d8, d9));
    boolean didCollide = toX != this.player.getX() || toY != this.player.getY() || toZ != this.player.getZ(); // Paper - needed here as the difference in Y can be reset - also note: this is only a guess at whether collisions took place, floating point errors can make this true when it shouldn't be...

注意到定义后面甚至有一句注释: Y 方向可能被重置, 浮点误差也可能导致这个为真. 这句话之前有一个 player.move, 这个函数和 moveTo 不一样, 他传入的是移动的增量而不是目的位置, 可以审计一下更深的代码确认一下. player.move 后, player.getX() 等值就会更新为当前位置加上移动增量, 后面的 toX 是接受到的数据包的位置. 在误差可以接受的范围内, 认为 toX 和移动后的 player.getX() 相同. 但是 didCollide 对浮点数相等的判断直接用的 !=, 所以假使有一点小小的的误差, 也能够使 didCollide = true. 这里就是判断如果玩家移动了, 那么去检查玩家与其他方块或者实体的碰撞. (还记得吗? 这个数据包并不是只有移动才会发送, 客户端每隔一段时间会发送这个数据包)

看一下 hasNewCollision:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    // Paper start - optimise out extra getCubes
    private boolean hasNewCollision(final ServerLevel world, final Entity entity, final AABB oldBox, final AABB newBox) {
        final List<AABB> collisions = io.papermc.paper.util.CachedLists.getTempCollisionList();
        try {
            io.papermc.paper.util.CollisionUtil.getCollisions(world, entity, newBox, collisions, false, true,
                true, false, null, null);

            for (int i = 0, len = collisions.size(); i < len; ++i) {
                final AABB box = collisions.get(i);
                if (!io.papermc.paper.util.CollisionUtil.voxelShapeIntersect(box, oldBox)) {
                    return true;
                }
            }

            return false;
        } finally {
            io.papermc.paper.util.CachedLists.returnTempCollisionList(collisions);
        }
    }
    // Paper end - optimise out extra getCubes

大概是从碰撞箱缓存中取出可能造成碰撞的, 然后逐一判断, 如果碰撞了则返回 true. 造成碰撞箱发生碰撞还是比较简单的, 可以发送 set player position 数据包, 将 Y 轴位置稍微往下, 使其与地面方块碰撞箱交叉. 可以在服务端代码中加入打印 didCollideteleportBack 来确认是否有效.

警告
测试的时候发现好像没这么容易造成 didCollide, 上面关于浮点数误差的理解可能是有问题的. 关于碰撞箱这块没有理解得特别清楚, 如果有师傅研究过还望不吝赐教.

说了这么多, 最后的利用其实就两步:

  1. 发送数据包, 向前走一小步
  2. 发送数据包, 向下一点造成碰撞
  3. 重复上述两步, 便可不触发事件在游戏中移动
技巧
其实在 LiveOverflow 的视频中关于 HackForums 的帖子也提到过这个 bypass, “teleport into a block”. 一些相关的文章也写到了这一点. 不过我暂时没有找到什么相关的 hack 用了这个, 可能是发送远距离数据包的适用性要比造成碰撞高很多吧.

发送或修改数据包有很多种方法, 我采用 fabric mod 来实现这一功能, mixin 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package org.d3ctf.d3craftexp.mixin;

import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.network.packet.c2s.play.PlayerMoveC2SPacket;
import net.minecraft.util.math.Vec3d;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;

@Mixin(ClientPlayerEntity.class)
public abstract class ClientPlayerEntityMixin {
    @Redirect(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/network/ClientPlayerEntity;sendMovementPackets()V"))
    private void injected(ClientPlayerEntity player) {
        Vec3d pos = player.getPos();
        Vec3d rot = player.getRotationVector();
        player.setPosition(pos.add(rot.multiply(0.06)));
        player.networkHandler.sendPacket(new PlayerMoveC2SPacket.Full(player.getX(), player.getY(), player.getZ(), player.getYaw(), player.getPitch(), true));
        player.setPosition(pos.add(new Vec3d(0, -0.01, 0)));
        player.networkHandler.sendPacket(new PlayerMoveC2SPacket.Full(player.getX(), player.getY(), player.getZ(), player.getYaw(), player.getPitch(), true));
    }
}

验题师傅用 go-mc 写的脚本也可以实现, 还有师傅用修改过的 node-minecraft-data 做出此题.

失败
感谢 Monad 师傅指正, 以下关于非预期的分析是错误的. 等我再研究研究qaq

假如服务器认为玩家需要被传送, 如上面分析过的移动过快, 那么会将传送的位置数据, 附带一个序列号 (代码中的 this.awaitingTeleport) 发给客户端, 让客户端重新设置玩家位置以同步. internalTeleport 函数结尾就是向客户端发送了这样的数据包. 如果客户端没有收到数据包, 那么可能会造成服务端玩家位置与客户端玩家位置不一致.

有一些服务端主动发出的传送, 如服务端使用命令传送一个玩家, 需要客户端在收到数据包后, 发送一个确认传送数据包 (有点握手的感觉). 服务端代码中的 handleAcceptTeleportPacket 处理这个数据包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    @Override
    public void handleAcceptTeleportPacket(ServerboundAcceptTeleportationPacket packet) {
        PacketUtils.ensureRunningOnSameThread(packet, this, this.player.getLevel());
        if (packet.getId() == this.awaitingTeleport) {
            if (this.awaitingPositionFromClient == null) {
                this.disconnect(Component.translatable("multiplayer.disconnect.invalid_player_movement"), org.bukkit.event.player.PlayerKickEvent.Cause.INVALID_PLAYER_MOVEMENT); // Paper - kick event cause
                return;
            }

            this.player.moveTo(this.awaitingPositionFromClient.x, this.awaitingPositionFromClient.y, this.awaitingPositionFromClient.z, this.player.getYRot(), this.player.getXRot()); // Paper - use proper moveTo for teleportation
            this.lastGoodX = this.awaitingPositionFromClient.x;
            this.lastGoodY = this.awaitingPositionFromClient.y;
            this.lastGoodZ = this.awaitingPositionFromClient.z;
            if (this.player.isChangingDimension()) {
                this.player.hasChangedDimension();
            }

            this.awaitingPositionFromClient = null;
            this.player.getLevel().getChunkSource().move(this.player); // CraftBukkit
        }

    }

这个函数诡异的地方就在于, 服务端只要对上了序列号, 就认为客户端发送的位置是正确的, 然后直接将玩家位置设置成确认传送数据包中的位置.

补充
internalTeleport 中也设置了 this.awaitingPositionFromClient, 但是只是把它当作一个临时变量, 然后就设置为 null 了, 没有要求客户端确认. 同时 this.awaitingTeleport 序列号增加, 会忽略之前 (由于网络原因还没有到达服务端) 的确认传送数据包. 这里我猜测客户端只要收到了传送数据包都会发送一个确认, 服务端是否处理这个由自己的逻辑决定, 如 internalTeleport 就不处理它.

插件的实现是在玩家加入游戏后, 使用 player.teleport 去传送玩家:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    @EventHandler
    public void onPlayerJoin(@NotNull PlayerJoinEvent event) {
        Player player = event.getPlayer();
        sendHello(player);
        preparePlayerLocation(player);
    }

    void preparePlayerLocation(@NotNull Player player) {
        Location location = randomPosition(player.getWorld());
        player.teleport(location);
        getLogger().info("set player " + player.getName() + " location to " + location);
        player.lookAt(0.5, HORIZON, 0.5, LookAnchor.FEET);
        getLogger().info("set player " + player.getName() + " look at flag");
    }

这个传送最终会由服务端发送数据包, 并且 需要客户端确认. 所以只需要修改这个确认数据包的位置到 (0, -60, 0) 即可.

虽然是个非预期, 但是满分冰美式的师傅也做得十分精彩!