- Published on
SEKAI CTF 2023 – Azusawa's Gacha World
- Authors
- Name
- Lumy
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
Unity3D game file
The challenge gives us a zip file containg the game challenge. Azusawa's Gacha World
Reverse Engineering
We can see trough splashscreen and files included that the game is based on Unity3D engine.
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)
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 ^^
Flag : SEKAI{D0N7_73LL_53G4_1_C0P13D_7H31R_G4M3}