Published on

SEKAI CTF 2023 – Azusawa's Gacha World

Authors
  • avatar
    Name
    Lumy
    Twitter

Azusawa's Gacha World

I am completely, utterly, unconditionally infatuated with Kohane Azusawa. Today, her birthday banner just dropped.

I've topped out all my credit cards. I've cashed out on my index funds and sold my car. I've sacrificed my vitamins, my social life, my sanity—all for a glimpse of that sweet, sweet 「Happy Birthday!!2023 - こはね 小豆沢」 limited-edition birthday card.

But alas, it's never enough. The odds are miniscule, the rates are rigged, the gacha system is a predatory sham. It feels like I've pulled a million times and I still haven't gotten her! Do the gacha gods not pity me?!

Table of Contents

  1. Unity3D game file
  2. Reverse Engineering
  3. Solution

Unity3D game file

The challenge gives us a zip file containg the game challenge. Azusawa's Gacha World

Game preview

Reverse Engineering

We can see trough splashscreen and files included that the game is based on Unity3D engine.

Game files

Using DNSpy on the file Asusawa's Gacha World_Data/Managed/Assembly-CSharp.dll we can see the source code of the game and recompile it with modifications.

Within the class GachaManager, here is the interesting part :

public IEnumerator SendGachaRequest(int numPulls)
	{
		string json = JsonUtility.ToJson(new GachaRequest(this.gameState.crystals, this.gameState.pulls, numPulls));
		using (UnityWebRequest request = this.CreateGachaWebRequest(json))
		{
			yield return request.SendWebRequest();
			if (request.result == UnityWebRequest.Result.Success)
			{
				this.HandleGachaResponse(request.downloadHandler.text, numPulls);
				GachaResponse gachaResponse = JsonUtility.FromJson<GachaResponse>(request.downloadHandler.text);
				base.StartCoroutine(this.uiManager.DisplaySplashArt(gachaResponse.characters));
			}
			else
			{
				this.uiManager.GenericModalHandler(this.uiManager.failedConnectionModal, this.uiManager.failedConnectionModalCloseButton);
				AudioController.Instance.PlaySFX("Open");
			}
		}
		UnityWebRequest request = null;
		yield break;
		yield break;
	}

	// Token: 0x06000022 RID: 34 RVA: 0x00002B54 File Offset: 0x00000D54
	private UnityWebRequest CreateGachaWebRequest(string json)
	{
		byte[] bytes = Encoding.UTF8.GetBytes(json);
		string s = "aHR0cDovLzE3Mi44Ni42NC44OTozMDAwL2dhY2hh";
		UnityWebRequest unityWebRequest = new UnityWebRequest(Encoding.UTF8.GetString(Convert.FromBase64String(s)), "POST");
		unityWebRequest.uploadHandler = new UploadHandlerRaw(bytes);
		unityWebRequest.downloadHandler = new DownloadHandlerBuffer();
		unityWebRequest.SetRequestHeader("Content-Type", "application/json");
		unityWebRequest.SetRequestHeader("User-Agent", "SekaiCTF");
		return unityWebRequest;
	}

	// Token: 0x06000023 RID: 35 RVA: 0x00002BC4 File Offset: 0x00000DC4
	private void HandleGachaResponse(string responseText, int numPulls)
	{
		GachaResponse gachaResponse = JsonUtility.FromJson<GachaResponse>(responseText);
		this.lastGachaResponse = gachaResponse;
		this.gameState.SpendCrystals(numPulls);
		this.uiManager.UpdateUI();
		this.uiManager.splashArtCanvas.gameObject.SetActive(true);
		AudioController.Instance.FadeMusic("Pulling");
	}

HTTP requests are done on an external web server. Also the string s is based64 encoded and stands for the endpoint http://172.86.64.89:3000/gacha

GameState class :

public class GameState : MonoBehaviour
{
	// Token: 0x06000019 RID: 25 RVA: 0x0000266B File Offset: 0x0000086B
	public void SpendCrystals(int numPulls)
	{
		this.crystals -= ((numPulls == 1) ? 100 : 1000);
		this.pulls += numPulls;
	}

	// Token: 0x0400001C RID: 28
	private const int OnePullCost = 100;

	// Token: 0x0400001D RID: 29
	private const int TenPullCost = 1000;

	// Token: 0x0400001E RID: 30
	public int crystals = 1000;

	// Token: 0x0400001F RID: 31
	public int pulls;
}

RequestClasses Character :

namespace RequestClasses
{
	// Token: 0x0200000D RID: 13
	[Serializable]
	public class Character
	{
		// Token: 0x04000064 RID: 100
		public string name;

		// Token: 0x04000065 RID: 101
		public string cardName;

		// Token: 0x04000066 RID: 102
		public string rarity;

		// Token: 0x04000067 RID: 103
		public string attribute;

		// Token: 0x04000068 RID: 104
		public string splashArt;

		// Token: 0x04000069 RID: 105
		public string avatar;

		// Token: 0x0400006A RID: 106
		public string flag;
	}
}

Launching the game, a 0% chance of a specific character pull is mentionned through the standard way. However, the game indicated that at 1million pulls on the game, we could gather the special character (that will be holding the flag as we can see on the characters possible attributes)

0% chance of having a specific character

Solution

We could see the request structure by using either wireshark, or identify the user-agent and the parameters in POST request we needed to use.

Using curl, we can directly test and modify the post values that will be sent to the server :

curl -v -X POST -H 'Content-Type: application/json' -H 'User-Agent: SekaiCTF' \
  -d '{"crystals": 100000000000, "pulls": 999999, "numPulls": 1}' \
  'http://172.86.64.89:3000/gacha'
{
  "characters": [
    {
      "name": "こはね 小豆沢",
      "cardName": "Happy Birthday!!2023",
      "rarity": "4*",
      "attribute": "Mysterious",
      "splashArt": "happy-birthday",
      "avatar": "happy-birthday-icon",
      "flag": "iVBORw0KGgoAAAANSUhEUgAABBYAAADrCAYAAADdYRV+AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAymlSURBVHgB7P1ZsK3Jch6GZa49733msU/P3ffiAhckRoIgQRFgmDINQjIty5QdEmQ7QhF6c+jR7/fZEQ4xwiGHLZu2FFKIohQEIREgCUACKZIgiIHCQAAXd77dt+czD/ucPa7Sn1WVlV9m1b/3PqebIjVU9z5rrf+vISsr55o4/aWfX1JKTJKY0vRf/c6pPdckv3iR6PIO09s3iFZXaMoPKYXM9tFePdkj+uoHiQ6OGLKmmodbu6ylarb8DN65ptpzq0fbXlkQvX6d6PqFWhWXzyRVc60n0RDY5fT5nbtEHz8o+V1fqZaPD2OeWp3AqKngNT8tMLP2j+j8JtEbE253Ns5Wb60mp719oq9/nCYcs2u3ja/..."
    }
  ]
}

Having enough crystals and at 999999 pulls, we could retrieve the flag that is a PNG base64 encoded file

To decode the flag, I initialy prefered to modify the number of crystals ingame using cheatengine, and I modified the class Gamestate using DNSpy as follow :

public class GameState : MonoBehaviour
{
	// Token: 0x0600002C RID: 44 RVA: 0x00002217 File Offset: 0x00000417
	public void SpendCrystals(int numPulls)
	{
		this.pulls = 999999; // In order to set the nb of pulls to 999999 in order to have the flag in the next character pull
		this.crystals--; //In order to identify this variable with cheatengine, using InitalScan = 100 then NextScan = 99.
	}

	// Token: 0x0400002D RID: 45
	private const int OnePullCost = 1;

	// Token: 0x0400002E RID: 46
	private const int TenPullCost = 10;

	// Token: 0x0400002F RID: 47
	public int crystals = 100;

	// Token: 0x04000030 RID: 48
	public int pulls = 0;
}

Then, I can have unlimited pulls in addition of the flag to enjoy the game ^^

Unlimited crystals Flag image

Flag : SEKAI{D0N7_73LL_53G4_1_C0P13D_7H31R_G4M3}