目录

2025 R3CTF Misc r3craft & r4craft

在 R3CTF 临期时出了个 Minecraft Hack 题, 差点难产了. 自认为出的不是很好, 和前年 给 D3CTF 出的那个 比差远了. 不过看最后看结果貌似还算可以? 至少问卷里喜欢这俩题的蛮多的.

附件给的就是远程部署环境, 是一个 1.21.6 的 paper mc 服务器, 加载了 GrimAC 和 R3Craft 插件. 此外, 附件里还有一些配置文件, 包括 bukkit (服务器的一些设置) 和 GrimAC 的. GrimAC 是一个反作弊插件, R3Craft 是主要的挑战插件.

注意
由于出题时 GrimAC 还没有发布 1.21.6 的 release, 故自行编译了一个.

启动服务器进入游戏, 可以发现被磨制安山岩围起来了, 由于高 1.5 格无法直接跳上围墙.

WingsZeng/R4Craft-Plugin

使用 java 反编译工具如 jd-gui 等可以逆向出 R3Craft.jar 的主要代码:

R3Craft.jar

package com.r3kapig.r3craft;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import org.bukkit.GameRule;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;

import ac.grim.grimac.api.GrimAbstractAPI;
import ac.grim.grimac.api.event.events.FlagEvent;
import ac.grim.grimac.api.plugin.BasicGrimPlugin;
import net.kyori.adventure.text.Component;

public class R3Craft extends JavaPlugin implements Listener {

  private final Object flagEventlock = new Object();
  private final Map<UUID, Integer> playerViolationCount = new HashMap<>();
  private final Map<UUID, Boolean> hasPlayerBeenKicked = new HashMap<>();
  private final Map<UUID, Boolean> playerLoginLocationValid = new HashMap<>();
  private long lastPlayerQuitTime = 0L;

  private final int Y_PLATFORM = -60;
  private final int Y_BORDER = Y_PLATFORM + 1;
  private final int Z_SPAWN = 0;
  private final int X_SPAWN = 0;

  @Override
  public void onEnable() {
    saveDefaultConfig();

    if (getServer().getPluginManager().getPlugin("GrimAC") == null) {
      getLogger().severe("GrimAC not found!");
      getServer().shutdown();
      return;
    }

    RegisteredServiceProvider<GrimAbstractAPI> provider = getServer().getServicesManager()
        .getRegistration(GrimAbstractAPI.class);
    if (provider == null) {
      getLogger().severe("GrimAC API not found!");
      getServer().shutdown();
      return;
    }

    getServer().getPluginManager().registerEvents(this, this);
    getCommand("flag").setExecutor(this);

    BasicGrimPlugin plugin = new BasicGrimPlugin(
        getLogger(),
        getDataFolder(),
        getPluginMeta().getVersion(),
        getPluginMeta().getDescription(),
        getPluginMeta().getAuthors());

    GrimAbstractAPI api = provider.getProvider();
    api.getEventBus().subscribe(plugin, FlagEvent.class, event -> {
      event.getPlayer().sendMessage(event.getVerbose());
      synchronized (flagEventlock) {
        UUID playerId = event.getPlayer().getUniqueId();
        Integer newViolationCount = playerViolationCount.put(playerId,
            playerViolationCount.getOrDefault(playerId, 0) + 1);
        if (newViolationCount != null
            && newViolationCount.intValue() > getConfig().getInt("kick-violation-threshold", 5)) {
          Boolean hasBeenKicked = hasPlayerBeenKicked.getOrDefault(playerId, false);
          if (hasBeenKicked == null || hasBeenKicked.equals(Boolean.FALSE)) {
            hasPlayerBeenKicked.put(playerId, true);
            getServer().getScheduler().runTask(this, () -> {
              Player player = getServer().getPlayer(playerId);
              if (player != null) {
                player.kick(Component.text("You have been kicked for excessive violations."));
              }
            });
          }
        }
      }
    });
  }

  @EventHandler
  public void onServerLoad(org.bukkit.event.server.ServerLoadEvent event) {
    World world = getServer().getWorld("world");
    if (world == null) {
      getLogger().warning("world not found!");
      getServer().shutdown();
      return;
    }

    world.setSpawnLocation((int) X_SPAWN, Y_PLATFORM, (int) Z_SPAWN);
    world.setGameRule(GameRule.SPAWN_RADIUS, 0);
    world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
    world.setTime(6000);

    for (int x = X_SPAWN - 1; x <= X_SPAWN + 1; x++) {
      for (int z = Z_SPAWN - 1; z <= Z_SPAWN + 1; z++) {
        if (x == X_SPAWN && z == Z_SPAWN)
          world.getBlockAt(x, Y_PLATFORM - 1, z).setType(Material.POLISHED_ANDESITE_SLAB);
        else
          world.getBlockAt(x, Y_PLATFORM, z).setType(Material.POLISHED_ANDESITE_SLAB);
      }
    }

    Material borderMat = Material.POLISHED_ANDESITE;

    for (int x = X_SPAWN - 2; x <= X_SPAWN + 2; x++) {
      world.getBlockAt(x, Y_BORDER, Z_SPAWN - 2).setType(borderMat);
      world.getBlockAt(x, Y_BORDER, Z_SPAWN + 2).setType(borderMat);
    }
    for (int z = Z_SPAWN - 1; z <= Z_SPAWN + 1; z++) {
      world.getBlockAt(X_SPAWN - 2, Y_BORDER, z).setType(borderMat);
      world.getBlockAt(X_SPAWN + 2, Y_BORDER, z).setType(borderMat);
    }
  }

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

    if (lastPlayerQuitTime > 0 && System.currentTimeMillis() - lastPlayerQuitTime < 1000) {
      player.kick(Component.text("Please wait a moment before rejoining."));
      return;
    }

    boolean valid = player.getLocation().getBlockY() <= Y_PLATFORM;
    playerLoginLocationValid.put(player.getUniqueId(), valid);
  }

  @EventHandler
  public void onPlayerQuit(PlayerQuitEvent event) {
    UUID playerId = event.getPlayer().getUniqueId();
    playerLoginLocationValid.remove(playerId);
    playerViolationCount.remove(playerId);
    hasPlayerBeenKicked.remove(playerId);
    lastPlayerQuitTime = System.currentTimeMillis();
  }

  @Override
  public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
    if (!(sender instanceof final Player player))
      return true;

    Runnable task = () -> {
      player.sendMessage(Component.text("NO FLAG FOR YOU!"));
    };

    if (player.isOnline() && !hasPlayerBeenKicked.getOrDefault(player.getUniqueId(), false)
        && playerLoginLocationValid.getOrDefault(player.getUniqueId(), false)
        && player.getLocation().getY() >= Y_BORDER + 2.2252) {
      task = () -> {
        if (player.isOnline() && !hasPlayerBeenKicked.getOrDefault(player.getUniqueId(), false)) {
          if (playerLoginLocationValid.getOrDefault(player.getUniqueId(), false)
              && player.getLocation().getBlockY() > Y_BORDER) {
            Component message = Component.text("CONGRATULATIONS!")
                .appendNewline()
                .append(Component.text(getConfig().getString("flag")));
            player.sendMessage(message);
          } else {
            player.sendMessage(Component.text("NO FLAG FOR YOU!"));
          }
        }
      };
    }

    getServer().getScheduler().runTaskLater(this, task, 10L);
    return true;
  }
}

插件主要功能如下:

  1. 初始化世界和设置: 设置平台和围墙, 出生点等 (L90 - L123)
  2. 作弊踢出机制: 使用 GrimAC API, 监听 Flag (玩家可能作弊) 事件, 强制踢出玩家 (L66 - L87)
  3. 处理 flag 命令, 根据检查条件判断是否给玩家 flag (L147 - L176)
  4. 玩家加入和退出的一些处理 (L125 - L145)

阅读代码并结合场景, 获得 flag 的条件为:

登录 / 出生在平台上, 并且玩家在比围墙顶部还高 1.2252 的位置发送 /flag 命令, 10 ticks (半秒) 后玩家高度依旧在围墙之上并且没有退出 (被踢出) 服务器.

而踢出的部分有一个逻辑漏洞, map.put 返回的是旧的值而不是新值 (L71 - L72). 也就是说, 如果是第一次触发 FlagEvent, 那么它返回的是 null, 从而不进行 if 语句块里的逻辑. 所以即使插件设置 (plugins/R3Craft/config.yaml) kick-violation-threshold = 0, 但其实能有一个绕过的空间.

信息
赛时了解到很多队伍都没有发现这个逻辑漏洞, 导致 r4craft 没有进展, 即使这个问题能被 gemini 一眼丁真.

GrimAC 是一个强大的反作弊插件, 他的 Simulation 功能可以在服务器预测玩家运动接下来的位置, 并和实际接收到的数据包做比较. 如果差值较大, 则可能触发作弊警报. 除此之外, 还有对一些数据包的检查, 比如是否发送飞行数据包, 是否直接离地等检查. 这就让一些著名的 hack 如 packetfly 等失效了, 所以使用 Wurst 等 Hack 客户端无法直接飞上围墙.

GrimAC 的配置和默认只有两处不同, 即 plugins/GrimAC/config.yaml 里, Simulation 下的 immediate-setback-thresholdmax-advantage. 二者值均被设置为 0.5001. 阅读注释或者查阅 GrimAC 的源码可以知道, 前者是立刻取消移动 (tp 回上一个合法位置) 的差值阈值, 后者是取消移动的累计偏差阈值. 结合上面的逻辑漏洞, 实际上我们有且仅有一次移动偏差在 0.5001 以内的机会 (实际上几乎可以确定了目标就是 0.5).

很多队伍都经过搜索发现了 MC-276267 这个 bug, 或者是 @DRWHOCOBY1e-7 Stepping 视频:

这个 bug 的机制如视频所说, 是 行走辅助Stepping 会将玩家抬高 0.5 格, 正好满足 GrimAC 的限制, 因此只要按照视频里所说的复现即可. 需要注意的是, 玩家需要面向 Z 轴负方向才可以实现 1e-7 Stepping.

赛后经过了解, 一些可能没有发现视频的队伍也用另一种方法达成了利用. MC-276267 中所述的场景和本题不同, 即标准设置的底部有一个方块. 这些队伍在底部放置幽灵方块的方法来欺骗客户端, 从而实现了 1e-7 Stepping. 非常厉害!

必须要在玩家跳跃的最高点发送 /flag 命令才能拿到 flag. 手动操作会有一些难度, 可以写一个 mod 来自动实现. 具体见 Exploit.

这题的唯一区别是, GrimAC 的 immediate-setback-thresholdmax-advantage 都被设置成了 0.25. 如之前所说的, 这个漏洞本质上存在于客户端, 客户端在判断玩家 “需要” 行走辅助后会发送一个移动数据包. 最简单的验证方法是在服务器上使用 grimac consoledebug 查看输出.

1e-7 Stepping 输出
1e-7 Stepping 跳跃输出, P 是 GrimAC 预测当前位置和上一个位置的差, A 是接收到的数据包实际偏差. 第一个数据包的 y 与预测有偏差, 实际接收到的包 y 方向上的运动高了 0.5 格
正常跳跃输出
正常跳跃输出

不过, 这个错误移动数据包不能随意发送. GrimAC 除了模拟行为之外还有多项检查. 可以修改插件, 将踢出功能注释掉, 加上一些输出, 就能知道何时为什么触发事件了:

java

    api.getEventBus().subscribe(plugin, FlagEvent.class, event -> {
      event.getPlayer().sendMessage(event.getVerbose());
      // synchronized (flagEventlock) {
      //   UUID playerId = event.getPlayer().getUniqueId();
      //   Integer newViolationCount = playerViolationCount.put(playerId,
      //       playerViolationCount.getOrDefault(playerId, 0) + 1);
      //   if (newViolationCount != null
      //       && newViolationCount.intValue() > getConfig().getInt("kick-violation-threshold", 5)) {
      //     Boolean hasBeenKicked = hasPlayerBeenKicked.getOrDefault(playerId, false);
      //     if (hasBeenKicked == null || hasBeenKicked.equals(Boolean.FALSE)) {
      //       hasPlayerBeenKicked.put(playerId, true);
      //       getServer().getScheduler().runTask(this, () -> {
      //         Player player = getServer().getPlayer(playerId);
      //         if (player != null) {
      //           player.kick(Component.text("You have been kicked for excessive violations."));
      //         }
      //       });
      //     }
      //   }
      // }
    });

模仿 1e-7 Stepping 的客户端行为, 先发送一个包抬高一点 (当然在限制范围内, 这里测试用的 0.24). 这里写了一个 fabric mod 来达到上述功能.

发送数据包后客户端收到的消息
发送数据包后客户端收到的消息. 一共收到 5 条 (第一条 verbose 为空), 表示一共触发了 5 次事件

MC 控制台输出如下:

text

[11:15:41] [Render thread/INFO]: [STDOUT]: [Move] PosOnly: 0.30518334258688407, -60.5, 0.699999988079071, onGround=true
[11:15:42] [Render thread/INFO]: [STDOUT]: [Move] PosOnly: 0.30518334258688407, -60.5, 0.699999988079071, onGround=true
[11:15:42] [Render thread/INFO]: [STDOUT]: [Move] Full: 0.30518334258688407, -60.26, 0.699999988079071, onGround=false
[11:15:42] [Render thread/INFO]: [System] [CHAT]
[11:15:42] [Render thread/INFO]: [System] [CHAT] claimed false
[11:15:42] [Render thread/INFO]: [System] [CHAT] .240000 /gl 1
[11:15:42] [Render thread/INFO]: [STDOUT]: [Move] PosOnly: 0.30518334258688407, -60.338400001525876, 0.699999988079071, onGround=false
[11:15:42] [Render thread/INFO]: [System] [CHAT] type=flying, packets=1
[11:15:42] [Render thread/INFO]: [System] [CHAT] type=end, packets=2
[11:15:42] [Render thread/INFO]: [STDOUT]: [Move] PosOnly: 0.30518334258688407, -60.5, 0.699999988079071, onGround=true
[11:15:43] [Render thread/INFO]: [STDOUT]: [Move] PosOnly: 0.30518334258688407, -60.5, 0.699999988079071, onGround=true

可以发现实际上发送的包触发了 3 个事件, 而之后客户端根据"重力加速度"计算出下一刻玩家的位置, 又发送的一个包, 触发了另外 2 个事件.

所以, 要绕过 GrimAC, 需要仔细检查 Flag 的触发条件, 在合适的时机发包. 或者仅仅需要一些运气.

查询一下字符串可以知道, 上述发包触发了如下

飞行检查:

FlightA.java

    @Override
    public void onPacketReceive(PacketReceiveEvent event) {
        // If the player sends a flying packet, but they aren't flying, then they are cheating.
        if (WrapperPlayClientPlayerFlying.isFlying(event.getPacketType()) && !player.isFlying) {
            flag();
        }
    }

onGround 检查, 数据包不能突然发送 onGround = false 的 (正常跳跃除外, 具体怎么实现的其实还不太了解):

GroundSpoof.java

    @Override
    public void onPredictionComplete(final PredictionComplete predictionComplete) {
        // Exemptions
        // Don't check players in spectator
        if (PacketEvents.getAPI().getServerManager().getVersion().isNewerThanOrEquals(ServerVersion.V_1_8) && player.gamemode == GameMode.SPECTATOR)
            return;
        // And don't check this long list of ground exemptions
        if (player.exemptOnGround() || !predictionComplete.isChecked()) return;
        // Don't check if the player was on a ghost block
        if (player.getSetbackTeleportUtil().blockOffsets) return;
        // Viaversion sends wrong ground status... (doesn't matter but is annoying)
        if (player.packetStateData.lastPacketWasTeleport) return;

        if (player.clientClaimsLastOnGround != player.onGround) {
            flagAndAlertWithSetback("claimed " + player.clientClaimsLastOnGround);
            player.checkManager.getNoFall().flipPlayerGroundStatus = true;
        }
    }

Simulation 检查:

OffsetHandler.java

    public void onPredictionComplete(final PredictionComplete predictionComplete) {
        if (!predictionComplete.isChecked()) return;

        double offset = predictionComplete.getOffset();

        CompletePredictionEvent completePredictionEvent = new CompletePredictionEvent(player, this, offset);
        GrimAPI.INSTANCE.getEventBus().post(completePredictionEvent);

        if (completePredictionEvent.isCancelled()) return;

        if ((offset >= threshold || offset >= immediateSetbackThreshold)) {
            advantageGained += offset;
            giveOffsetLenienceNextTick(offset);

            synchronized (flags) {
                int flagId = (flags.get() & 255) + 1; // 1-256 as possible values

                String humanFormattedOffset;
                if (offset < 0.001) { // 1.129E-3
                    humanFormattedOffset = String.format("%.4E", offset);
                    // Squeeze out an extra digit here by E-03 to E-3
                    humanFormattedOffset = humanFormattedOffset.replace("E-0", "E-");
                } else {
                    // 0.00112945678 -> .001129
                    humanFormattedOffset = String.format("%6f", offset);
                    // I like the leading zero, but removing it lets us add another digit to the end
                    humanFormattedOffset = humanFormattedOffset.replace("0.", ".");
                }

                String verbose = humanFormattedOffset + " /gl " + flagId;
                if (flag(verbose)) {
                    if (alert(verbose)) {
                        flags.incrementAndGet(); // This debug was sent somewhere
                        predictionComplete.setIdentifier(flagId);
                    }

                    if ((advantageGained >= maxAdvantage || offset >= immediateSetbackThreshold)
                            && !isNoSetbackPermission()
                            && violations >= setbackViolationThreshold) {
                        player.getSetbackTeleportUtil().executeViolationSetback();
                    }
                }
            }

            advantageGained = Math.min(advantageGained, maxCeiling);
        } else {
            advantageGained *= setbackDecayMultiplier;
        }

        removeOffsetLenience();
    }

包序列检查, 一个 tick 里不能发送多个 movement packets:

TickTimer.java

    @Override
    public void onPacketReceive(PacketReceiveEvent event) {
        if (!player.supportsEndTick()) return;
        if (isFlying(event.getPacketType()) && !player.packetStateData.lastPacketWasTeleport) {
            if (!receivedTickEnd && flagAndAlertWithSetback("type=flying, packets=" + flyingPackets)) {
                handleViolation();
            }
            receivedTickEnd = false;
            flyingPackets++;
        } else if (event.getPacketType() == PacketType.Play.Client.CLIENT_TICK_END) {
            receivedTickEnd = true;
            if (flyingPackets > 1 && flagAndAlertWithSetback("type=end, packets=" + flyingPackets)) {
                handleViolation();
            }
            flyingPackets = 0;
        }
    }

Simulation 检查是无法绕过的, 所以要想办法绕过其他. 不能在一个 tick 发多个包就修改某个包; 玩家跳跃后在空中时, 修改的包也不会引发 onGround 和飞行检查. 于是很自然的想法就是把玩家跳跃到最高点发送的那个包修改, 让 y 高一点.

最简单的方法是写个 Mod, 这里使用 Frabic 来实现.

WingsZeng/R4Craft-Exploit

核心代码如下:

ExampleModClient.java

package com.example;

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
import net.minecraft.text.Text;
import net.minecraft.util.math.Vec3d;

import org.lwjgl.glfw.GLFW;

public class ExampleModClient implements ClientModInitializer {

	private double lastYVelocity = 0.0;
	private boolean modifyPosition = false;

	@Override
	public void onInitializeClient() {
		KeyBinding modifyPositionKey = KeyBindingHelper.registerKeyBinding(new KeyBinding(
				"key.mymod.move",
				InputUtil.Type.KEYSYM,
				GLFW.GLFW_KEY_M,
				"category.mymod"));

		ClientTickEvents.END_CLIENT_TICK.register(client -> {
			if (client.player == null)
				return;

			if (modifyPositionKey.wasPressed()) {
				modifyPosition = !modifyPosition;
				client.player.sendMessage(
						Text.literal("Position modification " + (modifyPosition ? "enabled" : "disabled")),
						false);
			}

			ClientPlayerEntity player = client.player;
			Vec3d currentVelocity = player.getVelocity();

			if (currentVelocity.y > 0 && currentVelocity.y < 0.01) {
				if (modifyPosition) {
					client.player.setPosition(client.player.getX(), client.player.getY() + 0.249, client.player.getZ());
				}
			}

			if (lastYVelocity > 0 && currentVelocity.y <= 0) {
				client.player.networkHandler.sendChatCommand("flag");
			}

			lastYVelocity = currentVelocity.y;
		});
	}
}
注意
这个实现需要玩家潜行时跳跃, 否则后续还会发送不符合预期的移动数据包导致被踢出.
技巧

frabic 有 mod 开发示例, clone 这个仓库, 并运行 ./gradlew 即可自动构建.

此外, 如果感兴趣也可以尝试用其他机器人库来全自动化控制1.

赛时唯二两支做出此题的队伍均编写类似的 mod 完成利用, 或许这确实是最方便的方法.

Its possible to escape this? : r/Minecraft