While writing Part 1 of this post, the story was far from over. Due to the way you find the server having been explained, a lot of people started to join the server. That is why LiveOverflow decided to create a secret new 1.19.2 server that would be harder to find and has lots of new challenges. This post will cover finding the server and doing those challenges.

Finding the New Server

In Part 1 we originally found the IP on a sign in the video, and then when it changed we searched for the new IP by scanning a range of IPs for Minecraft servers and filtered them to find a server with "LiveOverflow Let's Play" as the description. This time though, it won't be this easy. The new server had very few recognizable features.

Likely, the new server is still hosted on Hetzner, so we'll only scan that range. Big companies will often register an Autonomous System Number (ASN), that has all the IP ranges simply listed. This is really useful for us if we want to scan a company for servers, so we can use a site like ipinfo.io to get a list of IPv4 ranges that Hetzner (AS24940) owns. I just copied them to a file and you should have a big list like this:

IPs

116.202.0.0/16
116.203.0.0/16
128.140.0.0/17
...
65.21.0.0/16
95.216.0.0/16
95.217.0.0/16

We can then use masscan again with the -iL argument to use the Input List. Just like before we'll scan the default 25565 port and output the results to a file:

Shell

$ sudo masscan -p 25565 -iL hetzner-ips.txt --rate 25000 -oJ hetzner-masscan.json --banners  # Do scan
Scanning 2295040 hosts [1 port/host]
...
rate: 24.61-kpps, 100.0% done, 0:00:00 remaining, found=5679

Now we have a hetzner-masscan.json file that contains IPs that have the 25565 port open. But it found over 5000 IPs, and with the simple Python script I used for the previous server, this will take ages. Luckily, there is an awesome tool I mentioned briefly in the previous part called cCheck. It was made by someone on the server and is very fast at getting Server List Ping information from a list of IPs. To use it, you simply pass the result from masscan to the tool, and it will start a bunch of threads all scanning the IPs and writing the information to an output file.

Shell

$ git clone https://github.com/cleonyc/ccheck.git && cd ccheck

$ cargo build --release

$ ./target/release/ccheck scan -p hetzner-masscan.json hetzner-output.json
✓ Found 3964 good servers out of 5679!

It found almost 4000 servers! Any one of these could be the new 1.19.2 LiveOverflow Server we're looking for.

Filtering the Results

Now the hetzner-output.json file contains all the Minecraft information in a nice JSON format. We can use jq '.[0]' hetzner-output.json to get an example of what data it contains:

JSON

{
  "version": "Paper 1.19.2",
  "protocol": 760,
  "ip": [
    "12.34.56.78",
    25565
  ],
  "players": ["Hackende", "LiveOverflow"],
  "favicon": "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAxklEQVR4nO2YwQnDQBDELiFFuf+Xu3I62DwlstJ3OBDCYNjX8zzPGbjve5rPdV3jbn//HtcFFEDggFIAgQNKAQQOKJ9//8//er/+CyiAwAGlAAIHlAIIHFC6B4zrAgogcEApgMABpQACB5TuAeO6gAIIHFAKIHBAKYDAAaV7wLguoAACB5QCCBxQCiBwQOkeMK4LKIDAAaUAAgeUAggcULoHjOsCCiBwQCmAwAGlAAIHlO4B47qAAggcUAogcEApgMCB45zzBQvtbdWtyBQfAAAAAElFTkSuQmCC",
  "motd": {
    "text": "A Minecraft Server",
    "bold": false,
    "italic": false,
    "underlined": false,
    "strikethrough": false,
    "obfuscated": false,
    "color": null,
    "extra": []
  }
}

Last time, the motd contained "LiveOverflow", which was how we found it. We can try this again to see if it works twice:

Shell

$ jq '.[] | select(.motd.text | test("liveoverflow"; "i"))' hetzner-output.json
{
  "version": "Paper 1.19.2",
  "ip": ...
  "motd": {
    "text": "\"LiveOverflow Let's Play\"",
    ...
  }
}

That looks good, but maybe a bit too good... There are honeypots people set up to try and make it harder to find the real server. This is one of them, so we'll need to look a bit harder.

Another thing we can try is to search for players online, like LiveOverflow himself or any other known players. I tried doing this for a while but nothing notable came up. Some more fields are the version and protocol. The version can contain any string, so I tried filtering out any common versions like "Paper" or "Vanilla", but again no interesting result. A filter like select(.protocol == 1337) can also try to find protocols that are 1337, this would be something that LiveOverflow could do as the number is infamous in the hacker community, but again, no results.

The last thing left is the favicon. I mostly ignored these big blobs of Base64, but after trying so many other things I had to take a look at it. These icons work by encoding the contents of a PNG file in Base64 and sending it as text. We can try a simple CyberChef recipe to decode it and view the image. Maybe the new server has a recognizable icon?

There are a lot of servers, but some likely have the same icon. There are also a lot of servers that don't even have an icon, so we could just export all the icons and go through them manually. We only need to parse the JSON, get the favicons, decode them and save them to a file, maybe with the IP as the filename. I made a quick Python script that does this:

Python

import json
from base64 import b64decode
from hashlib import md5

data = json.load(open("output-hetzner.json"))

done = []  # Icons already seen

for server in data:
    if server["favicon"]:
        decoded = b64decode(server["favicon"])
        
        hash = md5(decoded).hexdigest()
        if not hash in done:  # No duplicates
            ip = server['ip'][0]
            with open(f"icons/{ip}.png", "wb") as f:
                f.write(decoded)
        
            done.append(hash)

It also only keeps one of the duplicates, so we won't see many of the same images. After running the script with the results we found, we have a directory icons/ full of images, 1381 to be exact. That's quite a bit to go through, but doable. So I put on some music and started scrolling in my file viewer. I found a few servers with interesting icons and tried joining them. Eventually, I got to this icon that reminded me of the 🔴 red dot LiveOverflow uses in some places, but in a pixelated and glitchy style:

Joining the server, it turned out to be correct! But there were a few weird things. I was in creative mode, there was a tiny world border and every few minutes a demo popup and end credits were shown.

Challenge 1: Weird Packets

So we're on the server now, but some weird stuff is happening. To my client, it looks like I am in creative mode, but trying to get blocks from the menu doesn't work, and you also still take damage. My client thinks it's in creative, while the server thinks I'm in survival. Such a desync creates lots of weird behavior. We also can't do much on the server because we're inside a tiny world border. LiveOverflow must have been messing with some packets.

Just like we as the client can send weird packets to confuse the server, the server can send any packets to the client. Here, the server is sending packets to change our gamemode, set the world border, and pop up screens, while in reality, those things are all fake. We as the client must ignore or change some of these packets to be equal to what the server expects again. Luckily, we have learned Fabric modding, so let's take a look at some code. I searched for things relating to "demo" and eventually found the GameStateChangeS2CPacket:

Java

@Override
public void onGameStateChange(GameStateChangeS2CPacket packet) {
    NetworkThreadUtils.forceMainThread(packet, this, this.client);
    ClientPlayerEntity playerEntity = this.client.player;
    GameStateChangeS2CPacket.Reason reason = packet.getReason();
    float f = packet.getValue();
    int i = MathHelper.floor(f + 0.5f);
    ...
    } else if (reason == GameStateChangeS2CPacket.GAME_MODE_CHANGED) {  // Change gamemode
        this.client.interactionManager.setGameMode(GameMode.byId(i));
    } else if (reason == GameStateChangeS2CPacket.GAME_WON) {  // End credits
        if (i == 0) {
            // Sends a respawn packet to go back to the overworld VVV
            this.client.player.networkHandler.sendPacket(new ClientStatusC2SPacket(ClientStatusC2SPacket.Mode.PERFORM_RESPAWN));
            this.client.setScreen(new DownloadingTerrainScreen());
        } else if (i == 1) {
            this.client.setScreen(new CreditsScreen(true, () -> this.client.player.networkHandler.sendPacket(new ClientStatusC2SPacket(ClientStatusC2SPacket.Mode.PERFORM_RESPAWN))));
        }
    } else if (reason == GameStateChangeS2CPacket.DEMO_MESSAGE_SHOWN) {  // Demo screen
        GameOptions gameOptions = this.client.options;
        if (f == GameStateChangeS2CPacket.DEMO_OPEN_SCREEN) {
            this.client.setScreen(new DemoScreen());
        } else if (f == GameStateChangeS2CPacket.DEMO_MOVEMENT_HELP) {
            this.client.inGameHud.getChatHud().addMessage(Text.translatable("demo.help.movement", gameOptions.forwardKey.getBoundKeyLocalizedText(), gameOptions.leftKey.getBoundKeyLocalizedText(), gameOptions.backKey.getBoundKeyLocalizedText(), gameOptions.rightKey.getBoundKeyLocalizedText()));
        } else if (f == GameStateChangeS2CPacket.DEMO_JUMP_HELP) {
            this.client.inGameHud.getChatHud().addMessage(Text.translatable("demo.help.jump", gameOptions.jumpKey.getBoundKeyLocalizedText()));
        } else if (f == GameStateChangeS2CPacket.DEMO_INVENTORY_HELP) {
            this.client.inGameHud.getChatHud().addMessage(Text.translatable("demo.help.inventory", gameOptions.inventoryKey.getBoundKeyLocalizedText()));
        } else if (f == GameStateChangeS2CPacket.DEMO_EXPIRY_NOTICE) {
            this.client.inGameHud.getChatHud().addMessage(Text.translatable("demo.day.6", gameOptions.screenshotKey.getBoundKeyLocalizedText()));
        }
    ...
}

This packet is sent to the client in a few different cases, namely changing the gamemode with GAME_MODE_CHANGED, showing the end credits with GAME_WON, and showing the demo screen with DEMO_MESSAGE_SHOWN. These are already 3/4 of the packets we noticed were messing with us. This code tells the client what to do when these packets are received, so we can just change it so it ignores some of those things.

For the GAME_MODE_CHANGED and DEMO_MESSAGE_SHOWN we can just completely ignore the packets, as these will not happen during normal gameplay. But the GAME_WON packet is a bit different. It is also sent when you jump into the end portal to go back to the overworld (where it would normally show the credits), but the crucial part is that it sends a PERFORM_RESPAWN packet to teleport you back to the overworld. If we were to completely ignore sending this packet, we would be stuck in the end portal when we try to go back to the overworld!

But in our hypothetical code, we can just make sure to send this packet ourselves when the GAME_WON variant is received. So that should be it, time for coding. I decided to @Redirect the packet.getReason() function, to easily read the reason variable and see if it is one we want to ignore. If so, we can just return null to make the rest of the code not do anything anymore. A simple idea of the code could be this:

Java

@Mixin(ClientPlayNetworkHandler.class)
public class ClientPlayNetworkHandlerMixin {

    @Redirect(method = "onGameStateChange", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/packet/s2c/play/GameStateChangeS2CPacket;getReason()Lnet/minecraft/network/packet/s2c/play/GameStateChangeS2CPacket$Reason;"))
    private GameStateChangeS2CPacket.Reason getReason(GameStateChangeS2CPacket instance) {
        GameStateChangeS2CPacket.Reason reason = instance.reason;  // <-- ERROR: `reason` is private
        if (Keybinds.passiveModsEnabled) {
            if (reason.equals(GameStateChangeS2CPacket.DEMO_MESSAGE_SHOWN) ||  // Demo popup
                reason.equals(GameStateChangeS2CPacket.GAME_MODE_CHANGED)) {  // Creative mode
                // Completely ignore packets
                return null;
            } else if (reason.equals(GameStateChangeS2CPacket.GAME_WON)) {  // End credits (still send respawn packet)
                MinecraftClient.getInstance().getNetworkHandler().sendPacket(new ClientStatusC2SPacket(ClientStatusC2SPacket.Mode.PERFORM_RESPAWN));
                return null;
            }
        }
        return reason;
    }
}

But oh no! We can't just get this.reason like the original getReason() would because it is a private variable. This means only the class itself can access it, not from the outside like we are. But in Fabric we have a few more tricks up our sleeve, like @Accessors. These accessors allow you to turn private variables into public ones, so you can access them from your Mixin. To do this, you simply create a new interface in a file with a definition for the function that you will use to get the private value:

Java

@Mixin(GameStateChangeS2CPacket.class)
public interface GameStateChangeS2CPacketAccessor {
    @Accessor("reason")
    GameStateChangeS2CPacket.Reason _reason();
}

Now we just have to change the code in the getReason() function to cast the packet to our GameStateChangeS2CPacketAccessor, and use the function for the reason variable:

Java

GameStateChangeS2CPacket.Reason reason = ((GameStateChangeS2CPacketAccessor) instance)._reason();

Now we're allowed to access it, and it can correctly check the reason and act accordingly. Testing this in-game, it works perfectly! No more annoying screens, and we're in survival mode like the server expects. We only have the World Border left.

When looking for WorldBorder we quickly find the WorldBorderInitializeS2CPacket, which is responsible for setting the world border to a specific radius and center. We just need to change this radius, to be the default 30.000.000 blocks. If we want to be truly exact, we can even change the center back to 0 0 as it should be. Here is the source code that does this:

Java

public void onWorldBorderInitialize(WorldBorderInitializeS2CPacket packet) {
    NetworkThreadUtils.forceMainThread(packet, this, this.client);
    WorldBorder worldBorder = this.world.getWorldBorder();
    worldBorder.setCenter(packet.getCenterX(), packet.getCenterZ());
    long l = packet.getSizeLerpTime();
    if (l > 0L) {
        worldBorder.interpolateSize(packet.getSize(), packet.getSizeLerpTarget(), l);
    } else {
        worldBorder.setSize(packet.getSizeLerpTarget());
    }
    worldBorder.setMaxRadius(packet.getMaxRadius());
    worldBorder.setWarningBlocks(packet.getWarningBlocks());
    worldBorder.setWarningTime(packet.getWarningTime());
}

There are two simple setSize and setCenter functions, which we can both change to the default values. We can use a @ModifyArgs to change these calls:

Java

@ModifyArgs(method = "onWorldBorderInitialize", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/border/WorldBorder;setSize(D)V"))
private void setSize(Args args) {  // Set radius to default 30 million
    if (Keybinds.passiveModsEnabled) {
        args.set(0, 30000000.0D);  // radius
    }
}
@ModifyArgs(method = "onWorldBorderInitialize", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/border/WorldBorder;setCenter(DD)V"))
private void setCenter(Args args) {  // Set center to 0 0
    if (Keybinds.passiveModsEnabled) {
        args.set(0, 0.0D);  // x
        args.set(1, 0.0D);  // z
    }
}

When we now build again and join the server, the world border is gone! And together with the GameStateChange packets we solved earlier, the game is again completely as it should be, and there are no longer weird desync issues between the client and the server.

Challenge 2: LiveOverflow's Base

There wasn't very much to do on the new server at first. It was nice having it be so empty without griefers or it always being full, but after some time what looked to be a challenge came out. In the video where he explains the WorldGuard Bypass, it has an interesting link in the description:

Episode 14 Teaser: "A new beginning..."

This is an unlisted video, and at the time had less than 100 views. It showed LiveOverflow moving to a new 1.19.2 server, and at the end, he says "This is my new place. You wonder where I am? Here are my coordinates, come and find me." with a little teaser in the video that says "my coordinates: " which cuts off right before the coordinates would be shown.

The challenge here is to find that base. There might be some packet exploit to get a player's coordinates on a server, but these are super powerful and highly valuable. The chance that LiveOverflow wants us all the find such an exploit that is being looked for by so many other players is doubtful. But in this case, we get quite a bit more information, in the video itself. It shows him flying around the supposed location of his base, and we have the world seed already (64149200). In theory, we could just generate a lot of chunks offline to see if they match with the biomes/terrain/structures we see in the video.

This is what I tried doing at first, but I quickly realized that it takes a very long time to generate so many chunks. I used cubiomes which is a C library that has already implemented most Minecraft generation and should be very efficient. But I had almost no experience in writing C, and couldn't really get my idea's to work. When I got something to work, there were a ton of results. A Minecraft world is very big when searching in chunks. 60.000.000/16 * 60.000.000/16 = 14.062.500.000.000 would be the number of chunks in the overworld, that's right, 14 TRILLION. With the code I was writing, there were millions of results, and the code would have to run for months to finish.

Another well-known idea is using the texture rotation of blocks shown in the video because it turns out that the random rotation some blocks have to create variance is dependent on the x, y, and z coordinates. There are existing tools like TextureRotations that can crack the coordinates from a bunch of texture rotation samples. This is actually a common way some bases on other anarchy servers like 2b2t are found when people accidentally show these rotations in screenshots. The is one big problem though, LiveOverflow is one step ahead of us. This is such a well-known idea that he made a hack that changes the texture rotation so we don't know how the randomness is generated anymore, as shown in the video:

Bedrock Generation

I wasn't the only one having problems. Almost everyone on the server was trying to crack these coordinates, but no one succeeded yet. After a while, LiveOverflow made a new video "Server Griefed and New Beginnings ..." where the whole video was recorded at the new base. That would give a lot more information to narrow down results and might give other ideas.

I was quick to notice the bedrock floor in the nether at around 10:40 in the video. Bedrock generation is another simple way and clear way to test every chunk against the bedrock we see in the video. It is close enough to be able to make out individual blocks, so it won't be as much guesswork as trying to find the terrain for example. I wasn't sure how fast this generation could be done, maybe this is another hopeless thing that would take months to complete, but I could at least try.

I found a few existing tools that tried to replicate the bedrock generation to find coordinates. But it seemed none of them were fast enough or worked in the nether like the screenshot is. This being in the nether is a blessing and a curse, we can't really use existing tools, but it does cut coordinates in 8! As every block in the nether is 8 blocks in the overworld, and this goes for both x and z directions. This means it reduces the total amount of chunks to search by 64 times, and "only" 60.000.000/16/8 * 60.000.000/16/8 = 219.726.562.500 chunks are left. Every little bit helps though.

We are also already talking about only having to check each chunk in 16s because we don't need to check every coordinate. In the video, it shows where the chunks start and end with rendering, so we actually have the bedrock relative to the chunks, and only need to check every chunk instead of every possible coordinate.

Then the implementation. I did this together with Lucas, another player I met on the server. He saw me starting with the idea of cracking bedrock, and wanted to join it. With his experience in C, we decided to implement it in C first. C is also the fastest language if you know what you're doing, so we'll get an idea of performance as well.

Note: Later I reimplemented our C code in Rust, which I am way more experienced with. So the rest of the code I'll share here is written in Rust, but note that this was not the original. It's just better code that makes the idea clearer.

We start with trying to understand how bedrock is generated in Minecraft. All terrain generation happens on the server side, so let's look at the Paper source code again, searching for keywords like "bedrock".

I quickly found a SurfaceRules.RuleSource nether() in SurfaceRuleData.java talking about a bedrock_floor:

Java

public static SurfaceRules.RuleSource nether() {
        ...

        return SurfaceRules.sequence(
            SurfaceRules.ifTrue(new PaperBedrockConditionSource("bedrock_floor", VerticalAnchor.bottom(), VerticalAnchor.aboveBottom(5), false), BEDROCK), 
            SurfaceRules.ifTrue(SurfaceRules.not(new PaperBedrockConditionSource("bedrock_roof", VerticalAnchor.belowTop(5), VerticalAnchor.top(), true)), BEDROCK), 
            SurfaceRules.ifTrue(conditionSource5, NETHERRACK), SurfaceRules.ifTrue(SurfaceRules.isBiome(Biomes.BASALT_DELTAS), SurfaceRules.sequence(SurfaceRules.ifTrue(SurfaceRules.UNDER_CEILING, BASALT), 
            ...
    }

It passes "bedrock_floor", the vertical bottom, and 5 blocks above the bottom to a new PaperBedrockConditionSource(). Following this function, it creates a record where these arguments are saved. Scrolling down a bit we can also see an apply() function that does what we're looking for, deciding whether to place bedrock at that position or not:

Java

public PaperBedrockConditionSource(String randomName, net.minecraft.world.level.levelgen.VerticalAnchor trueAtAndBelow, net.minecraft.world.level.levelgen.VerticalAnchor falseAtAndAbove, boolean invert) {
    this(new net.minecraft.resources.ResourceLocation(randomName), trueAtAndBelow, falseAtAndAbove, invert);
}

public SurfaceRules.Condition apply(SurfaceRules.Context context) {
    boolean hasFlatBedrock = context.context.getWorld().paperConfig().environment.generateFlatBedrock;
    int trueAtY = this.trueAtAndBelow().resolveY(context.context);
    int falseAtY = this.falseAtAndAbove().resolveY(context.context);

    int y = isRoof ? Math.max(falseAtY, trueAtY) - 1 : Math.min(falseAtY, trueAtY) ;
    final int i = hasFlatBedrock ? y : trueAtY;  // True at y=0
    final int j = hasFlatBedrock ? y : falseAtY;  // False at y=5 or higher
    
    // Create random generator
    final net.minecraft.world.level.levelgen.PositionalRandomFactory positionalRandomFactory = context.randomState.getOrCreateRandomFactory(this.randomName());

    class VerticalGradientCondition extends SurfaceRules.LazyYCondition {
        VerticalGradientCondition(SurfaceRules.Context context) {
            super(context);
        }

        @Override
        protected boolean compute() {
            int y = this.context.blockY;
            if (y <= i) {  // i = 0 (bottom)
                return true;
            } else if (y >= j) {  // j = 5 (bottom + 5)
                return false;
            } else {
                double d = net.minecraft.util.Mth.map((double) y, (double) i, (double) j, 1.0D, 0.0D);  // Threshold
                // Get random value
                net.minecraft.util.RandomSource randomSource = positionalRandomFactory.at(this.context.blockX, y, this.context.blockZ);
                // Choice using threshold
                return (double) randomSource.nextFloat() < d;
            }
        }
    }

    return new VerticalGradientCondition(context);
}

It returns true if the Y position is 0, and false if the y position is 5 or higher. Then for the values in between there is a threshold d calculated by mapping the min and max of the y value, from 1.0 to 0.0. This means a high value like y=4 will produce a low d=0.2, and a low value like y=1 will produce a high d=0.8. This is what we expect from bedrock, a gradient that gets more filled as we go down.

Then a random number is generated using the coordinates of the block and saved in randomSource. This value is then compared to the threshold, and this decides if bedrock is placed at that coordinate. We want to recreate this, so let's look at how this randomSource value is created.

It uses the positionalRandomFactory defined above, which creates a random number generator to be used later in the code. The .getOrCreateRandomFactory() function is provided with randomName, which is the "bedrock_floor" string from earlier. But it is important to note that it is wrapped in new ResourceLocation() before that, and the .toString() implementation of this prepends "minecraft:" to the string.

Following this path we find the following logic:

Java

private RandomState(NoiseGeneratorSettings chunkGeneratorSettings, Registry<NormalNoise.NoiseParameters> noiseRegistry, final long seed) {
    // World seed is used to create this.random
    this.random = chunkGeneratorSettings.getRandomSource().newInstance(seed).forkPositional();
    ...
}

public PositionalRandomFactory getOrCreateRandomFactory(ResourceLocation id) {
    return this.positionalRandoms.computeIfAbsent(id, (id2) -> {
        return this.random.fromHashOf(id).forkPositional();  // .fromHashOf(id) is called here
    });                        |
}                        +-----+
                         V
default RandomSource fromHashOf(ResourceLocation seed) {
    return this.fromHashOf(seed.toString());  // .toString() turns into "minecraft:bedrock_floor"
}                     |
                      V
public RandomSource fromHashOf(String seed) {
    int i = seed.hashCode();  // Java default hashcode function
    return new LegacyRandomSource((long)i ^ this.seed);
}                 |
                  V
public LegacyRandomSource(long seed) {
    this.setSeed(seed);
}             |
              V
public void setSeed(long seed) {
    // this.seed = (seed ^ 25214903917L) & 281474976710655L
    if (!this.seed.compareAndSet(this.seed.get(), (seed ^ 25214903917L) & 281474976710655L)) {
        throw ThreadingDetector.makeThreadingException("LegacyRandomSource", (Thread)null);
    } else {
        this.gaussianSource.reset();
    }
}


public PositionalRandomFactory forkPositional() {
    return new LegacyRandomSource.LegacyPositionalRandomFactory(this.nextLong());
}                                 |                                     |
                                  V                                     |
public LegacyPositionalRandomFactory(long seed) {                       |
    this.seed = seed;  // this.seed = this.nextLong()                   |
}                                                                       |
                 V------------------------------------------------------+
default long nextLong() {
    int i = this.next(32); ---+
    int j = this.next(32); ---+
    long l = (long)i << 32;   |
    return l + (long)j;       |
}                             |
             V----------------+
public int next(int bits) {
    long l = this.seed.get();
    long m = l * 25214903917L + 11L & 281474976710655L;
    // this.seed = this.seed * 25214903917L + 11L & 281474976710655L
    if (!this.seed.compareAndSet(l, m)) {
        throw ThreadingDetector.makeThreadingException("LegacyRandomSource", (Thread)null);
    } else {
        return (int)(m >> 48 - bits);
    }
}

It's quite some logic to follow, but I've added some comments and arrows to make it more clear. In summary, uses the world seed to initialize this.random. Then the bedrock code alters the seed with the java hashcode of "minecraft:bedrock_floor", and finally the forkPositional() function iterates the seed a bit. Now that we have an idea of how to code works, we can try to reimplement it in another faster language, like Rust.

But don't be fooled, this code is not easy. It took me hours to get it working, but how did I know it works correctly? Minecraft doesn't just tell you the this.seed value in the intermediate state we just recreated. In-game, we can only observe the world seed as the input, and bedrock as the output. To test this code, we need to find some way to see this value Minecraft calculates in the background.

One useful idea would be to use IntelliJ's built-in Debugger. You can set breakpoints and step through the code slowly while seeing all the variables and their values. But simply running the Minecraft Client in Debug mode doesn't trigger a breakpoint at this bedrock generation code, likely because it runs in a separate thread or other Java optimizations. But to achieve the same result, we can recreate the high-level functions in a Fabric mod of our own, and then debug that. We'll register a simple /bedrock <x> <y> <z> command that uses the same functions as the generation would:

Java

public void onInitialize() {
    ...
    CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(literal("bedrock")
            .then(argument("pos", BlockPosArgumentType.blockPos())
            .executes(context -> {
                BlockPos pos = BlockPosArgumentType.getBlockPos(context, "pos");
                ServerWorld world = context.getSource().getWorld();

                boolean result = checkBedrock(world, pos);  // <--- Try setting a breakpoint here

                context.getSource().sendFeedback(Text.of("Bedrock at " + pos.getX() + " " + pos.getY() + " " + pos.getZ() +
                        ": " + (result ? "§atrue" : "§cfalse")), false);

                return 1;
            }))
    ));
}

private static boolean checkBedrock(ServerWorld world, BlockPos pos) {
    // Same functions as apply() function in source
    RandomSplitter randomSplitter = world.getChunkManager().getNoiseConfig().getOrCreateRandomDeriver(new Identifier("bedrock_floor"));

    int i = 0;
    int j = 5;

    int i2 = pos.getY();
    if (i2 <= i) {
        return true;
    }
    if (i2 >= j) {
        return false;
    }
    double d = MathHelper.map(i2, i, j, 1.0, 0.0);

    Random random = randomSplitter.split(pos.getX(), i, pos.getZ());

    return (double)random.nextFloat() < d;
}

This is now a server-side command, so in a singleplayer world where we have control we can try it out. Going to the nether you can look at some bedrock, and this command should give true at that coordinate. And netherrack/air should give false. It is important to note that this doesn't just get the stored block information from the chunk, that would be cheating. It generates the bedrock in the same way the game itself would, which makes it able to predict bedrock at locations that haven't been generated. I recommend trying this for yourself, and confirming it works.

Then to actually debug the piece of code, we can set a breakpoint at the function call, and run the Minecraft Client with the green bug icon, next to the normal green play button. Then when we run the /bedrock command in-game on some block, it hits the breakpoint and you can slowly step into all the functions it calls, and compare between values with your recreation of the code. This helped a lot with getting the code to work. (PS. Make sure it also works in the millions, as this might create overflow errors in your code)

We've now only looked at creating the generator, not yet predicting bedrock for coordinates. But now we confirmed the generator works, and we can continue. The next part should be simpler though because we can continue the same Debugger idea to see exactly what happens:

Java

public RandomSource at(int x, int y, int z) {
    long l = Mth.getSeed(x, y, z); ---+
    long m = l ^ this.seed;           |
    return new LegacyRandomSource(m); |
}                                     |
                      V---------------+
public static long getSeed(int x, int y, int z) {
    long l = (long)(x * 3129871) ^ (long)z * 116129781L ^ (long)y;
    l = l * l * 42317861L + l * 11L;
    return l >> 16;
}

public LegacyRandomSource(long seed) {
    this.setSeed(seed);
}            |
             V
public void setSeed(long seed) {
    // this.seed = (seed ^ 25214903917L) & 281474976710655L
    if (!this.seed.compareAndSet(this.seed.get(), (seed ^ 25214903917L) & 281474976710655L)) {
        throw ThreadingDetector.makeThreadingException("LegacyRandomSource", (Thread)null);
    } else {
        this.gaussianSource.reset();
    }
}

default float nextFloat() {
    // We've already seen .next(), here it is multiplied to give a float value from 0-1
    return (float)this.next(24) * 5.9604645E-8F;
}

So it takes the x, y, and z coordinates, does some more math, and gets a float value to compare against the threshold. We can add this to our Rust code to be able to generate bedrock positions (and use the Debugger to test).

Note: I've made my Rust code with some optimizations open source in BedrockFinder. It is the recreation of this bedrock generation algorithm including all the little details and edge cases, like overflowing values.

Paper Bug

This whole code seemed to be correct, but whatever I tried I couldn't find the coordinates. I thought there must be something wrong with the pattern, or the rotation, but eventually, I got a hint from someone to check the history of Paper. Maybe something changed recently?

And when scrolling through the commit history on the Paper GitHub page, I quickly found an interesting pull request talking about the bedrock pattern. This was only a few weeks ago at the time, so this was just changed. I looked at the code for bedrock generation in the newest paper release, but the LiveOverflow video might be a bit behind, possibly before this "bedrock fix". If it was indeed before the fix, there should be no holes in the bedrock, only pillars. And when we check the video, sure enough, there were no holes in any of the bedrock. So this was the problem, and looking at the code this commit changed it's laughably simple:

Diff

  double d = net.minecraft.util.Mth.map((double) y, (double) i, (double) j, 1.0D, 0.0D);
- net.minecraft.util.RandomSource randomSource = positionalRandomFactory.at(this.context.blockX, i, this.context.blockZ);
+ net.minecraft.util.RandomSource randomSource = positionalRandomFactory.at(this.context.blockX, y, this.context.blockZ);
  return (double) randomSource.nextFloat() < d;

In the old code, i was mistakingly used as the y value for creating a random number. If you remember, i was the bottom, so just 0. For every block the random number had the same y value, only the threshold was different, which is why you get pillars instead of holes. The fix for this in our own code is also very simple, just replace the y value with 0 in the hashcode() function. Testing it again and now comparing it to an older paper version, this works again.

Cracking the Bedrock Formation

Finally, we have implemented the bedrock generation algorithm and can get started on actually using it to crack the location shown in the video. We can take a screenshot and draw some visible blocks on there. YouTube compression didn't help but with yt-dlp I could get the best quality possible, which made it relatively clear. When we will try to brute-force this, bedrock (red) but also air (blue) will give us information. So I made sure to draw both in, and ended up with the following positions I was pretty sure of:

In the Rust code, we can create a simple for loop that goes through all the x and z chunks with (-3_750_000..3_750_000).step_by(16). With the first very naive approach, this code will be pretty slow, and it would take days before it finished. So I added a lot of optimizations.

Firstly, we already thought about only checking each chunk, as you can see in the orange lines above. But we didn't think about rotations yet, we don't know what direction LiveOverflow is looking in the video, so should we need to check all 4 directions? The answer is no! We can actually use some more information in the same clip. At 10:40 in the video, he mines the ancient debris that later reveals the bedrock we're cracking. Luckily, this ancient debris texture is not randomly rotated, the top texture is always facing the same direction. This means we can compare it to any ancient debris we place ourselves, and find out what direction the video was looking in:

From there we can see he is looking at the -X direction, so we'll only have to check that rotation. There are also some more optimizations possible in the code itself like for calculating the random float. It is multiplied to be in the 0-1 range and then compared to the threshold. But we can also just skip this multiplication, and directly compare it to a threshold where this multiplication is already taken into account. And we don't need to calculate d every time we check a coordinate. We'll only feed in 1-4 for y, so we can just precalculate these and use the correct value when comparing.

Lastly, we can use multithreading to speed up the search. Multithreading is simply running parts of the program at the same time because a CPU can do multiple things on multiple cores at the same time. That will also cause the program to use as much of the CPU as it can. My laptop has 12 cores, so I created 10 threads that all search a separate band of the X range. When any of the threads find a match, it will print it and continue searching. Again, see my BedrockFinder for the implementation of this.

There is one more thing left, putting the bedrock formation into code. We could go by hand and write down all the offsets of the blocks, or we can let more code do this for us! I created a simple Fabric command again that takes an area in coordinates, and spits out the code for these offsets:

Java

CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(literal("getcode")
        .then(argument("from", BlockPosArgumentType.blockPos())
        .then(argument("to", BlockPosArgumentType.blockPos())
        .executes(context -> {
            BlockBox area = BlockBox.create(BlockPosArgumentType.getLoadedBlockPos(context, "from"), BlockPosArgumentType.getLoadedBlockPos(context, "to"));
            ChunkPos centerChunk = new ChunkPos(area.getCenter());
            BlockPos start = new BlockPos(centerChunk.getStartX(), area.getMaxY(), centerChunk.getStartZ());

            int i = area.getBlockCountX() * area.getBlockCountY() * area.getBlockCountZ();
            if (i > 32768) {  // No ridiculously big area
                throw TOO_BIG_EXCEPTION.create(32768, i);
            }

            ServerWorld world = context.getSource().getWorld();

            for (BlockPos blockPos : BlockPos.iterate(area.getMinX(), area.getMinY(), area.getMinZ(), area.getMaxX(), area.getMaxY(), area.getMaxZ())) {
                String code = "";
                if (world.getBlockState(blockPos).getBlock() == Blocks.BEDROCK) {  // Is bedrock
                    BlockPos pos = blockPos.subtract(start);
                    code = String.format("generator.is_bedrock(chunk_x + %d, y + %d, chunk_z + %d) == true", pos.getX(), pos.getY(), pos.getZ());
                } else if (world.getBlockState(blockPos).getBlock() == Blocks.GLASS) {  // Is empty
                    BlockPos pos = blockPos.subtract(start);
                    code = String.format("generator.is_bedrock(chunk_x + %d, y + %d, chunk_z + %d) == false", pos.getX(), pos.getY(), pos.getZ());
                }
                if (!code.isEmpty()) {
                    context.getSource().sendFeedback(Text.of(code), false);
                }
            }

            return 1;
        }))
)));

Then we can just build the bedrock and air as glass in-game, and get the code by simply running this command over the area (here the red blocks are blocks we weren't sure enough of):

We still weren't sure of ourselves that the formation was 100% correct, so we decided to only check 12 blocks we were absolutely sure of at the start (see green lines in the drawing above), and these 12 blocks generate a lot of false positives. But then we do a count of all the other blocks in the formation to get a number of matches. If this is higher than 5 below the max, we count it as a match. This is a nice balance between performance and usability because if we were to count the total number of matches for all chunks, the loop would break way later, and it might be 10x slower. By checking these 12 blocks at the start that have to be correct, we only do this more expensive computation for a handful of chunks.

As I said in the beginning, our original implementation was in C, and this also triggered a webhook to let us know it found something (because it ran for around 2 hours). When we ran it with everything in place, we got a message about 1 hour in:

We got the coordinates! I instantly teleported to the coordinates in my test world with the same seed, and everything I checked matched. This had to be it. So I joined the server and got started on traveling there. I used my classic BoatFly technique on the nether roof to quickly fly there and when I got to the coordinates, sure enough, I found a nether portal going to the overworld. I entered the portal and looked around for the base (matching the video with ChunkBase as well) and eventually found the base:

Challenge 3: Sheep Ritual

Finally being at the base, there was one more challenge to do. In one of the videos, LiveOverflow hinted at there being something in the Ancient City beneath the base. When we visit it, there is a big portal like all other Ancient Cities have. But at the end of the path leading up to the portal, there is an enchantment table:

Simply clicking on the enchantment table, a message appears in the chat:

The ritual requires a Club Mate sacrifice...

These are the Club Mate bottles from a previous challenge. We need to use one of our Club Mate items to attempt the ritual, luckily I had made a shulker full of bottles when I was still at spawn.

When we try to use one of the bottles on the enchantment table, the ritual starts, and we get another message in chat:

Hackende's Sheep briefly glitched into this dimension!
QUICK! Kill the sheep with your hands!

And as you can see in the image above, a burning sheep spawns in the middle of the portal for a few seconds. We can try to fly over to the sheep quickly and try to hit it with our hands. Then we get the following message:

You killed Hackende's Sheep but the ritual can only be completed if I see you near the place of sacrifice. Try again!

So we need to kill the sheep, but also be at the place where we sacrificed our Club Mate, back at the enchantment table. But this distance is almost 100 blocks away! How can we be back that quickly?

Well, this challenge is quite simple compared to other challenges. I had actually already implemented a bad version of Reach in my public LiveOverflowMod. That is basically what we need to do here as well. We need to be at the enchantment table at the start, hit the sheep 100 blocks away, and then be back at the enchantment table again. We can simply do this by moving quickly, we don't need to be back instantly, we have a few ticks of leeway.

So how fast can we move? We'll check out the paper source code to find out. When looking at the movement code for the WorldGuard Bypass, we used the "moved too quickly!" check to reset the player's position. So when we move over 3000 blocks, the server won't accept that position. But what is the exact number?

Java

public void handleMovePlayer(PacketPlayInFlying packetplayinflying) {
    ...
    
    this.allowedPlayerTicks += (System.currentTimeMillis() / 50) - this.lastTick;
    this.allowedPlayerTicks = Math.max(this.allowedPlayerTicks, 1);
    this.lastTick = (int) (System.currentTimeMillis() / 50);

    if (i > Math.max(this.allowedPlayerTicks, 5)) {  // Max 5 packets
        PlayerConnection.LOGGER.debug("{} is sending move packets too frequently ({} packets since last tick)", this.player.getName().getString(), i);
        i = 1;
    }

    if (packetplayinflying.hasRot || d11 > 0) {
        allowedPlayerTicks -= 1;
    } else {
        allowedPlayerTicks = 20;  // If no rotation set, max packets becomes 20
    }
    double speed;
    if (player.getAbilities().flying) {
        speed = player.getAbilities().flyingSpeed * 20f;
    } else {
        speed = player.getAbilities().walkingSpeed * 10f;
    }

    if (!this.player.isChangingDimension() && (!this.player.getLevel().getGameRules().getBoolean(GameRules.RULE_DISABLE_ELYTRA_MOVEMENT_CHECK) || !this.player.isFallFlying())) {
        float f2 = this.player.isFallFlying() ? 300.0F : 100.0F;  // Max speed², is 100 normally

        // If speed² exceeds 100, meaning if speed exceeds `sqrt(100) = 10` blocks
        if (d11 - d10 > Math.max(f2, Math.pow((double) (org.spigotmc.SpigotConfig.movedTooQuicklyMultiplier * (float) i * speed), 2)) && !this.isSingleplayerOwner()) {
            // Teleport back
            PlayerConnection.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());
            return;
        }
    }
    ...
}

Here we see the "moved too quickly!" check triggers when you go further than 10 blocks in one packet. But it is important to note that we can send multiple packets per tick! Normally the maximum would be 5 packets per tick, but when you don't have rotation in your packet (seen by .hasRot), it will even bring the maximum to 20 packets per tick.

If we just take the 5 packets per tick (from testing I found this to be more consistent), we can move 5*10 = 50 blocks per tick. In 4 ticks we'll have traveled to the sheep and back. We just have to implement a simple mod again that moves to a specific entity by sending these packets quickly, hits the entity, and then goes back to the original position.

My idea was to have a simple keybind, that when you press it, finds the nearest sheep and does the Reach hack to hit it. It will create a sort of queue of packets, where a maximum of 5 will be sent per tick. In the code, I find the direction we need to go, and then move a maximum of 10 blocks into that direction per packet. I will also at the same time leave breadcrumbs to find my way back after hitting the sheep in the middle of the whole sequence. The queue should look something like this at the end:
[1, 2, 3, 4, 5, A, 4, 3, 2, 1, 0]
where 1-5 are the path to the sheep, A is the attack packet, and 0 is the original position. In code I decided to create a list where I insert packets into the middle to create this pattern:

Java

public static LinkedList<Packet<?>> packetQueue = new LinkedList<>();

private static <T> void addToMiddle(LinkedList<T> list, T object) {
    int middle = (list.size() + 1) / 2;  // Rounded up
    list.add(middle, object);
}

public static Entity getClosestOfType(EntityType<?> entityType) {
    if (mc.player == null || mc.world == null) {
        return null;
    }
    Entity closestEntity = null;
    double closestEntityDistance = Double.MAX_VALUE;
    for (Entity entity : mc.world.getEntities()) {
        if (entity.getType() != entityType) {  // Skip non-matching entities
            continue;
        }
        double distance = mc.player.squaredDistanceTo(entity);
        if (distance < closestEntityDistance) {  // Found a closer entity
            closestEntity = entity;
            closestEntityDistance = distance;
        }
    }
    return closestEntity;
}

// Triggered every tick
public static void checkKeybinds(MinecraftClient client) {
    ...
    while (reachKeybind.wasPressed()) {  // If keybind was pressed
        if (packetQueue.size() > 0) {
            return;  // Already running
        }
        Entity target = getClosestOfType(EntityType.SHEEP);
        if (target == null) {
            return;
        }

        client.player.sendMessage(Text.of(String.format("Reach: Hit §a%s §r(%.1f blocks)",
                target.getName().getString(), client.player.distanceTo(target))), true);

        Vec3d virtualPosition = client.player.getPos();
        // Move close enough to target
        while (true) {
            // If player is too far away, move closer
            if (target.squaredDistanceTo(virtualPosition.add(0, client.player.getStandingEyeHeight(), 0)) >= MathHelper.square(6.0)) {
                Vec3d movement = target.getPos().subtract(virtualPosition);
                double length = movement.lengthSquared();

                boolean lastPacket = false;
                if (length >= 100) {  // Length squared is max 100
                    // Normalize to length 10
                    movement = movement.multiply(9.9 / Math.sqrt(length));
                } else {  // If short enough, this is the last packet
                    lastPacket = true;
                }
                virtualPosition = virtualPosition.add(movement);
                // Add forward and backwards packets
                addToMiddle(packetQueue, new PlayerMoveC2SPacket.PositionAndOnGround(virtualPosition.x, virtualPosition.y, virtualPosition.z, true));
                if (!lastPacket) {  // If not the last packet, add a backwards packet (only need one at the target)
                    addToMiddle(packetQueue, new PlayerMoveC2SPacket.PositionAndOnGround(virtualPosition.x, virtualPosition.y, virtualPosition.z, true));
                }
            } else {  // If close enough, stop
                break;
            }
        }
        // Add hit packet in the middle and original position at the end
        addToMiddle(packetQueue, PlayerInteractEntityC2SPacket.attack(target, client.player.isSneaking()));
        packetQueue.add(new PlayerMoveC2SPacket.PositionAndOnGround(client.player.getX(), client.player.getY(), client.player.getZ(), true));
    }

    // Send packets from reach queue (max 5)
    int movementPacketsLeft = 5;
    while (packetQueue.size() != 0 && movementPacketsLeft != 0) {
        Packet<?> packet = packetQueue.remove(0);  // Pop from the queue
        if (packet instanceof PlayerMoveC2SPacket) {
            movementPacketsLeft--;
        }

        networkHandler.getConnection().send(packet);
    }
}

It's quite a bit of code, that is because there needs to be quite some logic involved. But in the end, it just does what we wanted it to do. It finds the closest sheep and sets that as the target. Then it creates a queue of packets that move towards the sheep, hit it, and then move back. These packets are then sent at a maximum of 5 per tick.

We can try this in-game by clicking on the enchantment table with a Club Mate bottle, and then quickly pressing the keybind to kill the sheep from far away, while still standing at the enchantment table. Here's a video showing it in action:

The reward for completing this challenge was a bold prefix for your name, meaning your HACKER rank became HACKER. That way you could see in chat again who completed the challenge.

Improvements

This hack is still a bit inconsistent where it sometimes teleports the player back to somewhere in the middle of the path. But I have made some slight improvements to help counter this.

Firstly, Minecraft itself is still sending normal movement packets while the reach hack is moving. This will cause it to sometimes send a wrong packet, and then the server will teleport you back somewhere in the middle of your journey. To fix this, I added a mixin to the function that sends packets to check if the reach hack is enabled. If so, it will not send through any other movement packets, only the ones we generate.

For usability, I changed the ability to not target the closest entity of a certain type, but to be a toggle. And when you click on an entity far away like you would hit it normally, it will target that clicked entity. This was a bit more work but is a lot more useful for not only this challenge but for normal gameplay as well.

Challenge 4: Bedrock Vault

For a while there wasn't much else to do. I spent most of the time improving other hacks, and actually exploring the server a bit. But eventually there was one more challenge, and quite a special one at that. LiveOverflow shared the following image in the chat:

There was no comment or anything about the image, just the picture itself. So the first step was likely to try and find this bedrock cube somewhere on the server. At the time I was still at the base over 2 million blocks away, so I started searching near there.

A few small details stood out to me in the image. Mainly, the ocean monument that is visible in the ocean below the cube. In the ocean monument the facing direction is even barely visible, as seen by the arches that normally lead the entrance. Far in the distance a small piece of land is also visible, but not much detail in there as everything is highly compressed.

We at least know a little bit. To start searching I opened up ChunkBase and panned around the base to find a few ocean monuments near it. In a singleplayer world I compared and compared, but nothing seemed to line up perfectly. I even remembered LiveOverflow showed a monument in a video once, with a lot more terrain near it. Using the terrain from the video I quickly found that ocean monument near the base, but sadly, no cube there either.

After a lot more searching I thought, what are some more notable places? Well, spawn of course. I had checked basically every monument near the base 2 million blocks away, so I decided to travel back to spawn, maybe it's that simple. So finally back at spawn I searched again, in the same way. Using ChunkBase to find monuments, and traveling to them in-game to see if there is anything. After visiting a few monuments, I randomly found it! This was it eventually, only about 5.000 blocks from spawn:

At that monument there is indeed a giant bedrock box, and with freecam we can see if there is anything special inside. Sure enough, there is a small 9x9 room, but with seemingly no way in. There is also a button in that small room, and on an outside wall as well.

We can't yet reach the button on the inside, but we can try to see what happens when we press the outside button:

LiveOvergod: Hackende TRIES TO ACCESS MY VAULT?

We "tried to access the vault". So the goal is probably to get whatever is in the vault, the other button. Somehow we need to press the button outside, and within a few seconds have pressed the button inside. But with these 50-block thick bedrock walls, that seems like an almost impossible task!

I have seen slow phase exploits before, where you can go through 1-block walls slowly and while taking suffication damage, but an exploit like that would not even work here as there is only a few seconds to travel all that distance. We need to find some way to essentially teleport into the vault, and press the button.

Clipping into The Vault

One more idea I had was to do with a popular hack named "vclip", or Vertical Clip. Apparently the serverside movement code does not check your path for collisions if you're only moving vertically. That means you can stand on top of for example the nether bedrock roof, and then send a position packet saying you are -10 blocks down, and the server would accept that and you are now below the roof again (if you didn't collide with blocks at your end position). This sounds perfect for what we want to do here, but there is one problem: The normal vclip only goes a maximum of 10 blocks, and around this vault are walls 50 blocks thick. With a simple vclip the final position would end up inside of a block, which is a collision, and will teleport you back.

But vclipping can go fast, there might be something viable here to teleport further distances with a similar idea. So let's look at the code to figure out why vclip works, and why it is normally limited to 10 blocks.

We'll take a look at the movement code again, as vclip simply sends a position packet downwards, nothing more. We looked at this function multiple earlier times for other challenges, so at this point it should get a bit familiar. The handleMovePlayer() function in ServerGamePacketListenerImpl.java:

Java

public void handleMovePlayer(ServerboundMovePlayerPacket packet) {
    // Coordinates from packets
    double d0 = ServerGamePacketListenerImpl.clampHorizontal(packet.getX(this.player.getX()));
    double d1 = ServerGamePacketListenerImpl.clampVertical(packet.getY(this.player.getY()));
    double d2 = ServerGamePacketListenerImpl.clampHorizontal(packet.getZ(this.player.getZ()));

    ...

    // Try to move (stopping at collisions)
    this.player.move(MoverType.PLAYER, new Vec3(d7, d8, d9));

    // Calculate difference between packet vs after collision
    d7 = d0 - this.player.getX();
    d8 = d1 - this.player.getY();
    d9 = d2 - this.player.getZ();
    if (d8 > -0.5D || d8 < 0.5D) {
        d8 = 0.0D;
    }

    d11 = d7 * d7 + d8 * d8 + d9 * d9;  // Length squared of the difference vector
    boolean flag2 = false;

    // If this difference is > 0.0625 by default, trigger "moved wrongly!" and teleport the player back
    if (!this.player.isChangingDimension() && d11 > org.spigotmc.SpigotConfig.movedWronglyThreshold && !this.player.isSleeping() && !this.player.gameMode.isCreative() && this.player.gameMode.getGameModeForPlayer() != GameType.SPECTATOR) {
        flag2 = true;  // Will tell it to teleport back later
        ServerGamePacketListenerImpl.LOGGER.warn("{} moved wrongly!", this.player.getName().getString());
    }
    ...
}

This is pretty interesting. At a first glance, it simply gets the position from your packet, tries to move there while stopping at collisions, then calculates the difference between those two to find if a collision happened. If so, it will simply teleport you back and print the "moved wrongly!" warning in the console. How could we vclip through blocks if it should trigger when we are stopped due to a collision?

Well, there is one weird detail you might have noticed. While calculating the difference between your packet and the position after collisions, another if statement happens with (d8 > -0.5D || d8 < 0.5D). Looking at it quickly you might think it's just a check to ignore a small y difference, which would be fine. But look at it again, it says || instead of &&! If the difference d8 is more than -0.5, or if it's less than 0.5, it will be set to 0.0. But this will always be true! This simple typo makes the check useless meaning d8 will always be true, regardless of how far away a collision might be. This is only true for the Y value however, which is why you don't often see a Horizontal Clip that can go through walls, but a Vertical Clip is easy.

Okay, now we know that collision is not a problem if we're moving vertically, but the question still remains, how can we move through 50 blocks at once? We already looked at the "moved too quickly!" check, which should limit us to 10 blocks as we calculated. But we went over the details of the code a little too quickly, let's look at it again a bit closer:

Java

public void handleMovePlayer(ServerboundMovePlayerPacket packet) {
    double d10 = this.player.getDeltaMovement().lengthSqr();  // = velocity
    // d11 = length squared of this packet
    double d11 = Math.max(d7 * d7 + d8 * d8 + d9 * d9, (currDeltaX * currDeltaX + currDeltaY * currDeltaY + currDeltaZ * currDeltaZ) - 1);
    ...
    
    ++this.receivedMovePacketCount;  // Increment counter
    int i = this.receivedMovePacketCount - this.knownMovePacketCount;
    
    double speed;
    if (this.player.getAbilities().flying) {
        speed = this.player.getAbilities().flyingSpeed * 20f;
    } else {
        speed = this.player.getAbilities().walkingSpeed * 10f;
    }

    if (!this.player.isChangingDimension() && (!this.player.getLevel().getGameRules().getBoolean(GameRules.RULE_DISABLE_ELYTRA_MOVEMENT_CHECK) || !this.player.isFallFlying())) {
        float f2 = this.player.isFallFlying() ? 300.0F : 100.0F;

        // The check that limits our travel distance
        if (d11 - d10 > Math.max(f2, Math.pow((double) (org.spigotmc.SpigotConfig.movedTooQuicklyMultiplier * (float) i * speed), 2)) && !this.isSingleplayerOwner()) {
            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());
            return;
        }
    }
    ...
}

The check it is all about has a few parameters. First, d11 - d10. This value is the delta of the movement packet we send, with the player velocity subtracted. LiveOverflow talked about this a bit as well, because if you could have a very high velocity on the serverside, this would subtract a lot from the delta meaning you can move a lot further. But in practice, this velocity value will be very low, basically zero, so we can ignore it for now.

If this delta squared is then greater than the maximum of f2, which is 100.0, and this whole pow(movedTooQuicklyMultiplier * speed * i, 2) expression, the check will trigger. The f2 value is fixed, but if we can somehow influence this other expression we could increase the maximum.

This first movedTooQuicklyMultiplier comes from the spigot config, which is set to 10.0 by default. We aren't the server owner, so we can't change this constant. Another simply one the speed that comes from a few lines above. The player cannot fly on the serverside, so its value will be the walkingSpeed * 10. This walking speed is set to 0.1 by default, meaning in total the speed will be exactly 1. So far this is what we expect, pow(10*1, 2) = 100 meaning the maximum delta squared would be 100, or 10 normal blocks.

But the last thing here is important, the i variable. It also comes from above and is calculated using receivedMovePacketCount - knownMovePacketCount. The receivedMovePacketCount variable is incremented every time this piece of code it ran, so on every move packet. But where does this other "knownMovePacketCount" come from? Using IntelliJ we can simply Ctrl+Click on the variable name to see its definition, and then the usages. In the tick() method it seems to be set:

Java

public void tick() {
    ...
    this.knownMovePacketCount = this.receivedMovePacketCount;
    ...
}

...set to the receivedMovePacketCount from earlier! So this received count increments on every received packet, but the known count is only updated every tick!

Let's see what this means. The i variable subtracts the known count from the received count. If you send a movement packet every tick, it should always be 1. But if you send multiple movement packets per tick, the difference gets larger and i increases! If i increases, so will the expression from earlier that calculates the maximum delta. So in summary, sending multiple movement packets in one tick will increase the maximum range you can move to, and will allow you to basically build up that maximum, and then do one big movement. Combined with the knowledge from before about vclip, this should allow us to go through more than 10 blocks vertically.

But how much exactly? We also looked breefly at how many packets we can send per tick with the Reach hack. The real maximum if you don't send rotation packets is 20, but we used 5 to make it more consistent. So in theory, in one single tick, we could simply send any 19 movement packets, and then move 190 blocks in the last packet. That's amazing!

Time for actually putting it into practice, with some more Fabric code. I thought it would make sense to have a client-side command like /vclip where you can specify a distance to travel. To implement such a command, Earthcomputers's ClientCommands was a good reference with examples. The real work this hack needs to do it actually pretty simple. We can have a for loop that sends 18 movement packets, and then the packet far away:

Java

dispatcher.register(literal("vclip")  // Vertical clip
    .then(argument("distance", integer())
        .executes(context -> {
            int distance = context.getArgument("distance", Integer.class);
            PlayerEntity player = context.getSource().getPlayer();

            Vec3d pos = player.getPos();
            Vec3d targetPos = pos.add(0, distance, 0);  // Add the specified distance

            for (int i = 0; i < 18; i++) {  // Send a lot of the same movement packets (at current position) to increase max travel distance
                networkHandler.sendPacket(new PlayerMoveC2SPacket.PositionAndOnGround(pos.x, pos.y, pos.z, true));
            }
            // Now that the maximum is higher, we can send the destination that is further away
            networkHandler.sendPacket(new PlayerMoveC2SPacket.PositionAndOnGround(targetPos.x, targetPos.y, targetPos.z, true));
            player.setPosition(targetPos);

            return 1;
        })
    )
);

Testing this in-game with something like 150 blocks, we teleport perfectly! We can even teleport through blocks as we know the Y collision check is messed up, so trying /vclip -150 for example will teleport you through the ground into the void!

Now only the challenge is left, but we have most of the solution already. You might think we need a horizontal clip to teleport from the button on the wall, to inside of the room. But remember, this cube also has a top and bottom. We can simply start at the button on the wall, move up and around the cube to be right above the small room, and then use our vclip to clip down and through the bedrock. Then finally press the button there to complete the challenge.

Just like with the Reach hack, we'll simply move in steps of 10 until we are at our destination, above the room. Maybe surprisingly, we then don't even have to "charge" vclip down, as we have already sent a handful of packets per tick to get there. This is enough to clip straight through the 50 blocks there and press the button, all within a single tick! It's pretty simple to implement, so I'll just show it in action here:

Fun fact: I did not actually discover this exploit by looking at the source code as explained above. Instead, I tried some "dynamic testing" where I tried random weird things until something worked. One idea I had was that I could maybe build up velocity by quickly moving back and forth between two positions less than 10 blocks away, and then use that velocity to clip a great distance. I implemented this idea and made it move back and forth multiple times per tick, accidentally receating actual exploit we created here. And it actually somewhat worked! This is also how I completed the challenge on the server intially. Only after that I looked at the code to find my weird back and forth movementts weren't even the cause, it was the multiple packets that did it.

After this challenge, a message is chat is sent and the reward was a classic hacker mask that you can equip:

LiveOvergod: NO WAY! Hackende STOLE MY MASK!

Conclusion

This is really the end. Like before I made my mod fully open-source on Github as LiveOverflowMod. This now also contains the updated Fabric mods I made for this new server.
In the end, we learned a lot about Game Hacking, especially Java/Fabric and source code analysis. As well as when to just do some dynamic testing to speed up the process.

I enjoyed this whole journey and was happily surprised at this new server and all its challenges. It was certainly not easy, but that is where you learn from the most. I hope you enjoyed this as much as I did, and keep hacking!