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 much 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. Check my GitHub, you’ll see why.
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.
Mmmm stretch. And is that a DALL-E 2 watermark in the bottom right corner?
Anyway, it’s a 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 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 the outline 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’s code
The obvious method to look at here first is the Click method.
Ghidra doesn’t initially show the class fields. 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. 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:
- Unity engine version
- Game version
img
’s sprite UVssoo
’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, many tools won’t extract the PlayerSettings
asset required for #2.
Unity Engine Version
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
.
Game version
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 downloading 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
.
Here it is in a hex editor:
Sprite UVs
Number three is the sprite’s UVs. I cheesed this one and made a new sprite in Unity and printed out the contents of Sprite.uvs
. I don’t know what fields Sprite.uvs
are supposed to be in UABEA.
soo’s text
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";
// found uvs by creating a new sprite and printing its uvs
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 putting it in CyberChef gives us the correct answer: XMAS{dealing_with_unity}
.