2025 R3CTF Misc r3craft & r4craft
在 R3CTF 临期时出了个 Minecraft Hack 题, 差点难产了. 自认为出的不是很好, 和前年 给 D3CTF 出的那个 比差远了. 不过看最后看结果貌似还算可以? 至少问卷里喜欢这俩题的蛮多的.
r3craft
Setup
附件给的就是远程部署环境, 是一个 1.21.6 的 paper mc 服务器, 加载了 GrimAC 和 R3Craft 插件. 此外, 附件里还有一些配置文件, 包括 bukkit (服务器的一些设置) 和 GrimAC 的. GrimAC 是一个反作弊插件, R3Craft 是主要的挑战插件.
启动服务器进入游戏, 可以发现被磨制安山岩围起来了, 由于高 1.5 格无法直接跳上围墙.
R3Craft.jar
使用 java 反编译工具如 jd-gui 等可以逆向出 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;
}
}
插件主要功能如下:
- 初始化世界和设置: 设置平台和围墙, 出生点等 (L90 - L123)
- 作弊踢出机制: 使用 GrimAC API, 监听 Flag (玩家可能作弊) 事件, 强制踢出玩家 (L66 - L87)
- 处理 flag 命令, 根据检查条件判断是否给玩家 flag (L147 - L176)
- 玩家加入和退出的一些处理 (L125 - L145)
阅读代码并结合场景, 获得 flag 的条件为:
登录 / 出生在平台上, 并且玩家在比围墙顶部还高 1.2252 的位置发送 /flag
命令, 10 ticks (半秒) 后玩家高度依旧在围墙之上并且没有退出 (被踢出) 服务器.
而踢出的部分有一个逻辑漏洞, map.put
返回的是旧的值而不是新值 (L71 - L72). 也就是说, 如果是第一次触发 FlagEvent
, 那么它返回的是 null
, 从而不进行 if 语句块里的逻辑. 所以即使插件设置 (plugins/R3Craft/config.yaml
) kick-violation-threshold = 0
, 但其实能有一个绕过的空间.
GrimAC
GrimAC 是一个强大的反作弊插件, 他的 Simulation 功能可以在服务器预测玩家运动接下来的位置, 并和实际接收到的数据包做比较. 如果差值较大, 则可能触发作弊警报. 除此之外, 还有对一些数据包的检查, 比如是否发送飞行数据包, 是否直接离地等检查. 这就让一些著名的 hack 如 packetfly 等失效了, 所以使用 Wurst 等 Hack 客户端无法直接飞上围墙.
GrimAC 的配置和默认只有两处不同, 即 plugins/GrimAC/config.yaml
里, Simulation
下的 immediate-setback-threshold
和 max-advantage
. 二者值均被设置为 0.5001
. 阅读注释或者查阅 GrimAC 的源码可以知道, 前者是立刻取消移动 (tp 回上一个合法位置) 的差值阈值, 后者是取消移动的累计偏差阈值. 结合上面的逻辑漏洞, 实际上我们有且仅有一次移动偏差在 0.5001
以内的机会 (实际上几乎可以确定了目标就是 0.5
).
1e-7 Stepping
很多队伍都经过搜索发现了 MC-276267 这个 bug, 或者是 @DRWHOCOBY 的 1e-7 Stepping 视频:
这个 bug 的机制如视频所说, 是 行走辅助 会将玩家抬高 0.5 格, 正好满足 GrimAC 的限制, 因此只要按照视频里所说的复现即可. 需要注意的是, 玩家需要面向 Z 轴负方向才可以实现 1e-7 Stepping.
赛后经过了解, 一些可能没有发现视频的队伍也用另一种方法达成了利用. MC-276267 中所述的场景和本题不同, 即标准设置的底部有一个方块. 这些队伍在底部放置幽灵方块的方法来欺骗客户端, 从而实现了 1e-7 Stepping. 非常厉害!
发送命令
必须要在玩家跳跃的最高点发送 /flag
命令才能拿到 flag. 手动操作会有一些难度, 可以写一个 mod 来自动实现. 具体见 Exploit.
r4craft
数据包
这题的唯一区别是, GrimAC 的 immediate-setback-threshold
和 max-advantage
都被设置成了 0.25
. 如之前所说的, 这个漏洞本质上存在于客户端, 客户端在判断玩家 “需要” 行走辅助后会发送一个移动数据包. 最简单的验证方法是在服务器上使用 grimac consoledebug
查看输出.


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

MC 控制台输出如下:
[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 的触发条件, 在合适的时机发包. 或者仅仅需要一些运气.
GrimAC Flag Event
查询一下字符串可以知道, 上述发包触发了如下
飞行检查:
@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
的 (正常跳跃除外, 具体怎么实现的其实还不太了解):
@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 检查:
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:
@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;
}
}
Bypass
Simulation 检查是无法绕过的, 所以要想办法绕过其他. 不能在一个 tick 发多个包就修改某个包; 玩家跳跃后在空中时, 修改的包也不会引发 onGround 和飞行检查. 于是很自然的想法就是把玩家跳跃到最高点发送的那个包修改, 让 y 高一点.
Exploit
最简单的方法是写个 Mod, 这里使用 Frabic 来实现.
核心代码如下:
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;
});
}
}