blog.oat.zone
λ
cd /reverse-engineering-minecraft-cosmetics/

Reverse-engineering Essential, for the cosmetics' sake

· by jill

Reversing a pretentious so-called "essential" Minecraft mod to get $4.99 cat ears for free, for everyone.


A while back I came upon a tiny mod for Minecraft called Essential, which claimed to be "essential" to Minecraft multiplayer. Its marketing was extremely pretentious and annoyed me and I was gonna leave it at that, but I was curious what the hell that meant.

As I struggled to zoom out the text (because, for some reason, zooming on the site does nothing to the font size), I scrolled down to find a feature list.

The mod seemed promising, offering 3 unique features. It offered easy uPnP multiplayer, a friend system with DMs and a cosmetics system. We'll be ignoring the first two for this article as they aren't too relevant, or interesting really.

The cosmetics interested me a lot because I've noticed a significant spike of interest in cosmetics for Minecraft that are compatible with multiplayer as of recently: Figura comes to mind as one of the best implementations of this idea, letting you script whatever you imagine, but things like Ears or that one Figura rip-off are also some examples I can think of that didn't exist a year or two before.

I opened their cosmetics selection to be greeted with.. a shop. You could pay real money to use cosmetics in-game, despite them often being very primitive. You have to pay $2 for 5 pixels that resemble cat ears.

Now, don't get me wrong. Some of the models are fairly high quality. It's clear to me that effort has been put in to make them all look appropriate to Minecraft's art style. But I don't understand charging actual money for such low-quality cosmetics. Especially with no packs or similar, forcing you to spend >$5 per item.

A bunch of backpacks, ranging from 4$ to 10$.

So, I did what any sane human would do and decided I would extract the models and shove them onto Figura, just for the fun of it. My goal was to get a DMCA e-mail, as I figured my current count of 0 copyright law-related e-mails had to change after being on the internet for so long.


I figured the best place to look for them would be the mod itself. I know that Minecraft internally stores models in code rather than in asset files (specifically entity models); so I figured some decompilation was going to be necessary. I downloaded the mod (which wasn't easy, as the installer told me I didn't even have MC installed), and looked inside the .jar to find a.. wrapper.

A bunch of files packed in a .jar. Of notable interest is the essential-loader file.

The mod that you download, both on their official site and their installer, is the wrapper. It holds the auto-updater (essential-loader.jar), internally referred to as stage0, and loads it as soon as possible.

More files, packed in a .jar.

This loads the stage1, embedded in the jar, and auto-updates it if needed. I figured this would probably be it, but I still didn't find any assets or anything - it only had 3 classes.

3 classes, packed in a .jar.

So I decompiled these to find that it downloads a stage2 from an API:

Code that downloads a file, specified by variables outside of the codeblock.

After fetching https://downloads.essential.gg/v1/mods/essential/loader-stage2/updates/stable/fabric_1-18-1, you can now get a stage2, which has a lot more assets! We've got progress! However, from what I could tell, it still didn't have any of the assets or models anywhere.

Even more classes, packed in a .jar.

My first ideas were that either there was a stage3, or this downloads the models externally. After poking around some more, I found out that stage2 is ALSO an auto-updater - this time with a GUI, and specifically for stage3. So I dug around some more and found the stage3 download:

Constants that define the structure of the download link.

Fetching https://downloads.essential.gg/v1/mods/essential/essential/updates/stable/fabric_1-18-1 will now give us a download to stage3, and this one is much bigger at 16MB. I figured that this must be the end of it, and started poking around in the assets, but I still couldn't find the models or textures. I found a language file, however, which stored every cosmetic's name, so I figured they'd be somewhere in here. But, alas, after poking around for much longer, I couldn't find anything.

The rendering, equipping, GUI code assumes that the cosmetics are already populated - they're present in one convenient property and can be accessed through that. The assets were also gotten from them - the cosmetics stored asset URLs and those are provided by whatever initializes the Cosmetic class. I couldn't figure out how to get a Cosmetic, though.

What I did find after looking around for a while is a ServerCosmeticsPopulatePacket class, which took its input and registered it as a cosmetic. I knew that this had to be it, and decided to dive deep into where this comes from - the networkmanager.

What the networkmamager does is establish a WebSocket connection to wss://connect.essential.gg/v1, and as its first packet sends an authentication packet of sorts, which is initialized like so:

public ClientConnectionLoginPacket prepareLoginAsync() {
  this.authenticated = false;
  byte[] sharedSecret = gg.essential.connectionmanager.common.util.LoginUtil.generateSharedSecret();
  String sessionHash = gg.essential.connectionmanager.common.util.LoginUtil.computeHash(sharedSecret);
  int statusCode = LoginUtil.joinServer(this.minecraftHook.getSession(), this.minecraftHook.getPlayerUUID().toString().replace("-", ""), sessionHash);
  if (statusCode != 204) {
    Essential.logger.warn("Could not authenticate with Mojang - connection attempt aborted.");
    if (!this.triedReauth) {
      this.triedReauth = true;
      Essential.logger.warn("Trying to refresh session token..");
      AccountSwitcher.refreshCurrentSession(false);
    }
    return null;
  }
  return new ClientConnectionLoginPacket(this.minecraftHook.getPlayerName(), sharedSecret);
}

Now we were getting something interesting! It's authenticating with Mojang, and then generating a ClientConnectionLoginPacket. However, you'll note here that Mojang authentication isn't actually required(?) - all the data that's sent at the moment is the username and a sharedSecret, which is generated with gg.essential.connectionmanager.common.util.LoginUtil.generateSharedSecret();.

Taking a peek at LoginUtil I got really excited, as I was greeted with some fun constants:

private static final byte[] SHARED_CONSTANT = new BigInteger("173be201d4e5591dcef37bcaf701d136", 16).toByteArray();

However, the actual shared secret, disappointingly, is just a 16b random hash:

public static byte[] generateSharedSecret() {
  byte[] bytes = new byte[16];
  SECURE_RANDOM.nextBytes(bytes);
  return bytes;
}
SECURE_RANDOM is an instance of Java's SecureRandom class in this context.

So, okay, we've gotten the payload for ClientConnectionLoginPacket that we need to send on connection; how, do we, exactly do that?

The way packets are sent are they're sent through a magic send method on the Connection class:

this.send(loginPacket, ((Consumer) response2 -> {
  // handle the response, etc...

The send method is an interesting one:

int packetTypeId = (Integer) this.outgoingPacketTypeIds.computeIfAbsent((Object) this.splitPacketPackage(packet.getClass()), packetName -> {
  int newId = this.packetTypeId.incrementAndGet();
  this.send(new ConnectionRegisterPacketTypeIdPacket(packetName, newId), null, null, null, null); // (this function)
  return newId;
});
byte[] packetBytes = this.gson.toJson(packet).getBytes(StandardCharsets.UTF_8);
byte[] packetIdBytes = packetId != null ? packetId.toString().getBytes(StandardCharsets.UTF_8) : this.EMPTY_BYTE_ARRAY;

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

DataOutputStream dataOutputStream = new DataOutputStream(byteArrayOutputStream);
dataOutputStream.writeInt(packetTypeId);
dataOutputStream.writeInt(packetIdBytes.length);
dataOutputStream.write(packetIdBytes);
dataOutputStream.writeInt(packetBytes.length);
dataOutputStream.write(packetBytes);

this.send(byteArrayOutputStream.toByteArray());

So it first sends the packetType, which represents the individual packets' types, then it sends the UUID of the packet that it's responding to (which can be null, optionally, in which case it just sends a single NUL byte). After that it sends a JSON (of course..) of the packet, which in our case, would be {"username":"Oatmealine","sharedSecret":[array of 16 numbers]}.

It also seems to send a ConnectionRegisterPacketTypeIdPacket if a packet's type ID is not known, which increments the current ID counter and sends the server the packet name and its new assigned ID. Its payload looks something like this: {"className":"ClientConnectionLoginPacket","packetId":1}. Interesting!

As for how to identify this packet, it sets its own type ID to 0 on connection, both when receiving and sending it:

String packetName = this.splitPacketPackage(ConnectionRegisterPacketTypeIdPacket.class);
this.incomingPacketTypeIds.put(0, packetName);
this.outgoingPacketTypeIds.put(packetName, 0);

Okay, so our current plan is as follows: we initiate a connection with the server, then send a RegisterPacketTypeId packet (with packet type ID 0), clarifying the ID of ClientConnectionLoginPacket. After that, we send said packet with a shared secret and our MC username. After that? No clue! But we should have a correctly established connection, and the server should then be able to give us a hint at where to go next in the form of a packet.

Our goal after doing so will be to find a way to get the server to send us a ServerCosmeticsPopulatePacket, specifically one for each cosmetic, and then we've gotten ourselves the asset URLs we can use to fetch the textures and models.


So I made a script that would send our exact plan over to the WebSocket server. No response. You wait a little and it disconnects you with a LOGIN_TIMEOUT. That was odd, but I decided I should probably actually install the mod and poke around with the logs in Wireshark for a little bit, just to double-check what I'm doing is correct. So I did that, and, yes! I was mostly correct!

A Wireshark packet, showing a JSON with "ClientConnetionLoginPacket".
Remember our RegisterPacketTypeId? That's one right there!

Now, the two things I got wrong were:

  1. The GSON config they have encodes the individual keys as a single alphanumeric character rather than their full name, resulting in a, b, c, and so on. I disregarded the GSON config at first, since I figured it wasn't relevant.
  2. The class name was connection.ClientConnectionLoginPacket, not just the last part.

But that was it! And with those changes tweaked, I connected once more and...

← +{"a":"response.ResponseActionPacket","b":1}
00000000000000000000002b7b2261223a22726573706f6e73652e526573706f6e7365416374696f6e5061636b6574222c2262223a317d
← $accea420-5a5b-4ae8-b7b3-bda4e9442acf {"a":false}
000000010000002461636365613432302d356135622d346165382d623762332d6264613465393434326163660000000b7b2261223a66616c73657d
! connection closed: 4001 AUTHENTICATION_FAILED

Huh. Well this is odd. It registers a ResponseActionPacket just to tell us we failed at authenticating. What does.. authentication even mean in this context?

I had assumed it was the 16 random bytes I got wrong; either they're not actually 16 purely random bytes or I misread some code and this is actually converted at one point. So I took a closer look at the code, praying that I don't actually have to log into Mojang's servers...

As it turned out, I most definitely needed to authenticate with Mojang for this. The shared secret (hashed through computeHash) is sent to Mojang once you have an active session set up, and then the server checks if said shared secret is valid with Mojang's servers, letting you authenticate with the session.

I managed to integrate all of this into my script with prismarine-auth and LoginUtil.class's code, and...

Lots of incomprehensible data.

OH BOY. Juicy data. We've OBTAINED it.

There was, a LOT of data sent my way instantly. So much data that it instantly filled my terminal's history. So, I figured now is the time to parse it and save it all for further inspection.

Numbered JSON files, named after the packet class name.

Ooh, hold on, I think I spot our cosmetics in 6-cosmetic.ServerCosmeticsPopulatePacket.json!

A JSON file that contains CDN links to assets relating to cosmetics.

We have now OBTAINED the JUICY DATA.

Of course, the data is still a tiny bit fucked; the keys are a, b, c, d, etc instead of proper keys; that's fine though! We have all the data we need to piece together the models!

I wrote up something that would save this to cosmetics.json, and we're now done with the hard part!


May 15 addenum

I recently went back to the .jar files to see if I can find any other interesting packets that weren't captured throughout my searches. I quickly found ClientModsAnnouncePacket, which sends to the server the Minecraft version of the client, a checksum of every mod, and the mod loader they use. This is fairly standard and makes sense; however looking at the implementation for the mod checksums (which uses MD5), I found something peculiar:

modId != "feather" ? checksum : "e3d04e686b28b34b5a98ce078e4f9da8"

For some reason, the mod would send a fake checksum if you were using a mod with the ID "feather". After extensive digging, I found out that this is the Feather client's mod ID, and I also found out that Feather and Essential have... a bit of a rough history. In short, Feather was caught multiple times stealing code from Essential.

This kind of explains it, but it also begs the question why this happens. feather and the checksum aren't anywhere else in the codebase, and with this fake checksum sent each time you use the Feather client it means the developers and the server can always know when you have the mod active, regardless of Minecraft or mod version.

That's kinda shady. That's why I felt like making a short update here. It really confused me and still does; and feeding the server that the packet with that gave me nothing out of the ordinary.


To start off with our conversion, I needed to study both of the formats - the Essential cosmetic format and the Blockbench model format. The Essential cosmetic format is simple enough - to match up our keys, we can just use the Essential class constructor:

A small diagram, showing how the class constructor values closely match up with the JSON values.

The only data we need to construct the model is assets and settings - the other ones seem to serve no functional purpose in actually constructing the model, including tags.

The assets have (up to) 5 keys - the thumbnail, texture, geometry (for Steve and Alex models), the animations and the skin mask (for Steve and Alex). Each asset stores a URL and a checksum. I'm unsure as to why the checksum is needed when they're not used anywhere else, but sure.

The most important parts to us is the texture and geometry - the skin mask seems to be something that blacks out parts of the skin texture to indicate "do not render these". The texture is self-explanatory enough; but let's look at the model format.

Okay, this doesn't look too bad! Taking a closer look, it looks to be using hardcoded names for where the "bones" are actually parented to - they say they're parented to the root but that's just the internal empty bone that everything is parented to, and I don't see any other way it'd be parenting it to the arms or similar.

Converting this to the even simpler blockbench models, and accounting for weird quirks like mirror or box/per-face UVs, we've got ourselves...

A pair of wings. The textures are flipped.

Hm. Wait, hold on..

A pair of wings. The textures are no longer flipped.

There! Compare this to the thumbnail of this cosmetic:

That same pair of wings, now rendered as a cosmetic preview.

Pretty accurate, right? Now we can just shove it into Figura as it'll accept just about any Blockbench model:

The pair of wings, now on my Minecraft skin.

Voilà! We've got ourselves pirated wings! How cool is that?

This works for just about any model with some tiny adjustments (like replacing body with Body, left_arm with RightArm (yeah, they're mirrored for some reason) and similar tweaks):

A flaming sword on my Minecraft skin's back.

I settled for this quite nicely! Though, there were still a couple of issues I couldn't fix without Figura scripting:

  1. Animations, specifically animated textures
  2. Animations, specifically animating the movement and rotation of parts
  3. Hiding parts of the skin based on the skin mask

The first was fairly easy to do, so I made a features function that would include a script if one was needed:

However the second was going to be tricky as the animation format I haven't even yet looked at. Let's take a look, shall we?

One thing I noticed right away is that the Essential animation format is just GeckoLib's animation format, and Blockbench has support for GeckoLib.

So it was really smooth porting it over, actually!

The only thing that was going to be definitely a challenge is the triggers system GeckoLib has:

A trigger defined via JSON, containing a type, target, probability, name, and some other miscellaneous properties.

It has weird probability stuff, skips, loops, priority, targets, everything. I had no clue how to interpret any of that, and couldn't find anything in the docs, so I just went through every animation to try to better my understanding of them, and 1 hour later, I had somewhat working triggers!

I was getting very close! Now I just needed to iron out some stuff and everything would be done!

One issue I couldn't figure out was the mirror property. It's applied to parts and (seemingly) mirrors their texture. However, mirroring textures when the mirror property is applied breaks every model that uses it?? And mirroring textures when the mirror property isn't applied fixes almost every model??? Except for some where the model can only be fixed by ignoring the property altogether???


Right, so a few days of suffering later, I think I'm quite happy with what i have achieved. It translates most models smoothly, and they're usable in Figura quite well.

I've published most of my work in this repository, where you can also download the resulting models.

You might notice that I haven't uploaded the converter script, however. This is mostly because it has exposed secrets I can't be bothered to hide, but also because I don't want the Essential devs to be able to spot one of the dumb hacks I've done and patch it out without affecting the rest of the userbase.

Regardless, I hope you enjoy what I've done here. Maybe it'll inspire you to reverse-engineer other similarly commercially questionable projects and/or cash grabs, or maybe it'll inspire you to spam the comments with "mald" and "too poor to afford the 6$ elephant backpack, L".


published
flooferland

mald (too poor to afford the 6$ elephant backpack, L)
:3 :3
been looking at ur blogs for a bit and I like how eerily technical ya go xD. Also a fellow trans girl (very based!!)