XMAS 2022 - Presents?

Categories: re
2022-12-21
by not_really

Santa made an Android app for the ~naughty bois. You you were good during the year, then you must know the password for the presents.

By: edmund

I wasn’t planning on participating in this CTF because of IRL reasons, but as soon as I heard there was a rare Unity challenge, I couldn’t pass it up.

Taking a look

We’re provided an apk so we’ll extract it with 7-Zip (since it’s just a zip file renamed) and get the goodies. Looking in the lib/arm64-v8a folder, we can see the game uses il2cpp. This means the C# code which would normally be using CIL bytecode has instead been compiled to C++. That makes our lives a little harder, but not unexpected for a Unity Android game. The first step is to get some kind of decompiled code.

Running the app

Since the app was only provided with an arm64 binary and BlueStacks doesn’t seem to support that, I installed the app on a real, physical phone to get an idea of what is going on. Here’s what it looks like.

Pretty simple flag checker. We type something in, hit a button, and a label shows whether we are correct or not.

Decompiling the code

Everyone’s go to for this is il2cppdumper. It doesn’t necessarily “decompile” but it does dump the metadata on method names, field names, etc. It also has a script to load this information into Ghidra. Cpp2IL could be another choice, except neither the old nor new versions work for this file, so il2cppdumper is the only choice. We can dump the dlls by running il2cppdumper.exe, choosing lib/arm64-v8a/libil2cpp.so and then assets/bin/Data/Managed/Metadata/global-metadata.dat. This will give us a dump.cs file (the skeleton of all the classes, methods, etc), dummy dlls (the same but in compiled form), and script.json. The dummy dlls are useful for viewing data.unity3d which contains all of the assets (scenes, sprites, etc.) and script.json is useful for Ghidra.

First let’s load everything into Ghidra. We can run ghidra_with_struct.py right after autoanalysis kicks in in Ghidra and wait for about half an hour for it to analyze. Once that’s done, we can take a look at the classes. I prefer to look at this by opening Assembly-CSharp.dll in dnSpy so I can filter out all of the Unity code. Assembly-CSharp.dll contains all of the game scripts made by the developer and doesn’t include any other scripts that come from Unity or other plugins. dnSpy would normally decompile code, but remember that dummy dlls are only a skeleton of all the classes and methods and doesn’t include any actual code.

Inside Assembly-CSharp.dll we see three classes: AES, Guess_button, and Readme. Readme doesn’t look all that important but Guess_button and AES do. Let’s look at Guess_button first.

Guess button

The obvious method to look at here first is the Click method.

Ghidra isn’t showing the fields for our class right now. I thought il2cppdumper’s _with_struct script was supposed to handle this but apparently it doesn’t. dump.cs shows us that Guess_button has four fields:

// Fields
public TextMeshProUGUI textBox; // 0x18
public TextMeshProUGUI soo; // 0x20
public TextMeshProUGUI input; // 0x28
public Image img; // 0x30

We can right click param_1, click Auto Create Structure, and edit the structure so it looks like what il2cppdumper says.

Great, so it’s slightly clearer now but there’s still a bunch of other missing pointers. Because they’re easy to guess, I didn’t bother making structs for them. Let’s take a look:

plVar2 = param_1->soo;
if (plVar2 != (long *)0x0) {
    uVar3 = (**(code **)(*plVar2 + 0x548))(plVar2,*(undefined8 *)(*plVar2 + 0x550));
    uVar3 = Guess_button$$StringToByteArrayFastest(uVar3);

soo is a TextMeshProUGUI. (+0x548) probably reads text from the label, but we’ll have to verify this later. StringToByteArrayFastest converts a hex string to a byte array as found by a Google search and a StackOverflow post.

plVar2 = param_1->input;
if ((plVar2 != (long *)0x0) && (lVar4 = (**(code **)(*plVar2 + 0x968))(plVar2,*(undefined8 *)(*plVar2 + 0x970)), lVar4 != 0)) {
    uVar5 = System.String$$Remove(lVar4,*(int *)(lVar4 + 0x10) + -1,0);

input is also a TextMeshProUGUI but we can assume by the name it has a textbox inside. This one uses (+0x968) which could be getting the textbox’s text. It does String.Remove(text.Length - 1, 0) on it which would normally remove the last n characters from the string. However, because there is a 0, no characters are removed. I could be wrong about this and it could be Ghidra adding an argument that isn’t there. Regardless, it shouldn’t be that big of a deal since this is just on the input.

    local_50 = 0;
    uStack72 = 0;
    if ((param_1->img != 0) && (lVar4 = *(long *)(param_1->img + 0xd0), lVar4 != 0)) {
        uVar6 = UnityEngine.Sprite$$get_uv(lVar4,0);
        UnityEngine.Hash128$$Append<Vector2>(&local_50,uVar6,Method$UnityEngine.Hash128.Append<Vector2>());
        uVar6 = UnityEngine.Hash128$$ToString(&local_50,0);

img is an Image. Get the image’s sprite and get its uv. Add it into a Hash128, then do Hash128.ToString(). The C# would look like this:

Hash128 newHash;
Vector2[] uvs = img.uv;
newHash.Append(uvs);
string newHashStr = newHash.ToString();

Moving on:

plVar2 = (long *)System.Text.Encoding$$get_ASCII(0);
if (plVar2 != (long *)0x0) {
	uVar5 = (**(code **)(*plVar2 + 0x238))(plVar2,uVar5,*(undefined8 *)(*plVar2 + 0x240));

uVar5 is the result from String.Remove (where we read the input textbox) and we do something with Encoding.ASCII on it. Since we know it returns a string, the only logical thing you could do with that class is call Encoding.ASCII.GetBytes. So this will convert the string to ASCII encoded bytes.

plVar2 = (long *)System.Text.Encoding$$get_ASCII(0);
uVar7 = UnityEngine.Application$$get_version(0);
uVar8 = UnityEngine.Application$$get_unityVersion(0);
uVar6 = System.String$$Concat(uVar6,uVar7,uVar8,0);
if (plVar2 != (long *)0x0) {
	uVar6 = (**(code **)(*plVar2 + 0x238))(plVar2,uVar6,*(undefined8 *)(*plVar2 + 0x240));
	uVar5 = AES$$Apply(uVar5,uVar6);
	uVar9 = Guess_button$$cmp_bytes(uVar5,uVar3);
	if (param_1->textBox != 0) {
		puVar1 = (undefined8 *)&StringLiteral_2673;
		if ((uVar9 & 1) == 0) {
			puVar1 = (undefined8 *)&StringLiteral_2640;
		}
		TMPro.TMP_Text$$SetText(param_1->textBox,*puVar1,1,0);
		return;
	}
}

We get the game version and the Unity version and concat them with the previous Hash128 result. The concatenated string is then converted to a byte array again, then passed into AES.Apply along with the bytes from the input. If it matches the hex string from earlier, we’re good.

Also, AES.Apply is not actually AES. According to the dummy dll, its signature is this:

public static byte[] Apply(byte[] data, byte[] key)
{
    return null;
}

Searching this on GitHub’s code search returns an… RC4 class. Nice.

Getting the unknowns

So now we have a lot of unknown values we need to find. Specifically:

  1. Unity engine version
  2. Game version
  3. img’s sprite UVs
  4. soo’s text

The easiest way for someone taking a naive approach might be to load up GDB on an Android, set a breakpoint, and view what the values are in the debugger. I can’t do that because as far as I know, GDB on Android requires a rooted phone, and since an emulator won’t work, I’m stuck. (And no, I can’t root. My phone is bootlocked, thanks Verizon.)

A method that could work is by patching the code to print out the values. I don’t know how difficult this is but it sounds like a lot of work.

The last solution is to just use a Unity tool for viewing/editing. I’m a bit biased to use my own tool UABEA since I made it. Also, all of the other tools will struggle at getting #2 because of how they are written, so we might as well get them all with one tool.

Number one is the Unity engine version. That’s the easiest one to get. Opening data.unity3d in a hex editor will reveal the version at the top of the file. In this case, the version is 2021.3.15f1.

Number two is the game’s version. That information can be found in data.unity3d/globalgamemanagers in the PlayerSettings asset in the bundleVersion field. First, drag data.unity3d into UABEA. Click Memory when asked how to decompress. Make sure globalgamemanagers is selected in the dropdown and click info to open the file info list. Click the asset with the type PlayerSettings and click info. Initially, it says something about the asset failing to deserialize. This can be fixed by grabbing the latest tpk from the Tpk repo here: https://github.com/AssetRipper/Tpk/actions. Replacing that in the directory with UABEA and restarting will fix that issue. You could also just export raw and look for the thing that looks like a version. Here, it’s 25.12.2022.

Number three is the sprite’s UVs. I cheesed this one and made a new sprite in Unity and printed out its UVs. I don’t know what fields are supposed to represent Sprite.uvs in UABEA.

Number four is soo’s text. You can open the info for level0 and press F8 to open the GameObject viewer. UABEA crashes at first because Cpp2IL doesn’t work on this game. Deleting/renaming global-metadata.dat and adding the il2cppdumper dummy dlls into the Managed folder fixes this issue. Browsing around, we find that ButtonObj is the one with the Guess_button script, and we can open the soo pointer to find the text:

Solving

Great, so we’ve got everything we need. This was the code that should’ve worked.

public class solve : MonoBehaviour
{
    void Start()
    {
        const string GAME_VERSION = "25.12.2022";
        const string ENGINE_VERSION = "2021.3.15f1";
        Vector2[] uvs = new Vector2[]
        {
            new Vector2(0f, 1f),
            new Vector2(1f, 0f),
            new Vector2(1f, 1f),
            new Vector2(0f, 0f)
        };
        Hash128 hash = new Hash128();
        hash.Append(uvs);

        byte[] key = Encoding.ASCII.GetBytes(hash.ToString() + GAME_VERSION + ENGINE_VERSION);
        byte[] check = StringToByteArrayFastest("6ab916c453127ecaa82e41aac63121b9dcb7bc78e7ba773e"); // from soo's text

        byte[] output = AES.Apply(check, key);
        Debug.Log(Encoding.UTF8.GetString(output));
    }
	// private static byte[] StringToByteArrayFastest(string hex), see https://stackoverflow.com/a/9995303
	// private static int GetHexVal(char hex), same stack overflow post
}
public class AES
{
	// public static byte[] Apply(byte[] data, byte[] key), see https://github.com/manbeardgames/RC4/blob/master/RC4Cryptography/RC4.cs
}

For some reason, the output came out a little mangled. Logging key and check and putting it in CyberChef gives us the correct answer: XMAS{dealing_with_unity}.