Post

Jet Racing Extreme: Postmortem of a Dead Activation Server

Jet Racing Extreme: Postmortem of a Dead Activation Server

A friend mentioned to me that a game currently sold on Steam is unplayable. It softlocks on the main menu, and you have to forcibly Alt+F4 out of the game.

This seemed like a fun night adventure, so off we went. Given that this is an older Unity title, I chose client-side decompilation over traffic interception.

My goals were to understand the “custom” DRM implemented, as well as whether the game was truly dead or if it had fail-safes. I’ve also documented the internal licensing API and custom cloud saving below.

For clarity, this is not an analysis on how to implement cross-platform saving, licensing and monetization solutions, but the opposite. Alongside severe design flaws, the code reveals extreme security concerns such as:

  • No HTTPS;
  • No crypto;
  • Incomplete HMAC-style validation;
  • No signature or binary verification;
  • Extremely simple license state tracking;
  • No replay protection.

Target

  • Architecture: PC (32-bit)
  • Protections: .NET Reactor, basic hashing

Tools

  • dnSpyEx (32-bit, for breakpoints)
  • de4dot (mobile46’s fork)

Analysis

The first behavior I noticed is a curious error within the game’s log:

1
ProtectedPad, verifyPaidVersion() ERROR: Could not resolve host: jrex.srj-studio.com; Host not found

Opening the assembly \JReX\jrex_Data\Managed\Assembly-CSharp.dll in dnSpyEx revealed a familiar sight: obfuscated classes, strange string decryptors with hundreds of switch cases, and incorrectly named arguments for functions.

1
2
3
4
5
6
7
8
9
10
11
12
internal static byte[] rdNar2g86(byte[] \u0020)
{
    ...
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num7, num8, num9, num10, 0U, 7, 1U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num10, num7, num8, num9, 1U, 12, 2U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num9, num10, num7, num8, 2U, 17, 3U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num8, num9, num10, num7, 3U, 22, 4U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num7, num8, num9, num10, 4U, 7, 5U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num10, num7, num8, num9, 5U, 12, 6U, array);
    GOZuFrNRq1XKEntOOO.p03EDr9is(ref num9, num10, num7, num8, 6U, 17, 7U, array);
    ...
}

This would immediately break any attempt to recompile code outside of IL and be a chore to map. Tossing it into de4dot fixes this issue while maintaining it coherent to the game itself.

1
2
3
de4dot v3.1.41592.3405

Detected .NET Reactor (C:\Data\Dev\de4dot\Release\net45\Assembly-CSharp.dll)

With the trivial obfuscation out of the way, we can start mapping the game’s DRM.

Internal ID handling and savegames

We’ll first go over how the game assigns a player to its saving and licensing implementation.

Since the game is cross-platform, it uses a mix of Steam ID or a device ID provided by Unity’s deviceUniqueIdentifier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Token: 0x06000740 RID: 1856 RVA: 0x00037E60 File Offset: 0x00036060
 public static string GetUserID(bool forcesteamid = false)
 {
  if (Application.isEditor) // Unity editor case
  {
   return "76561198178452025"; // probably the developer's steam ID
  }
  if (forcesteamid)
  {
   return SteamUser.GetSteamID().m_SteamID.ToString();
  }
  if (PlayerPrefs.GetInt("NewDataModel", 0) > 300)
  {
   return SteamUser.GetSteamID().m_SteamID.ToString();
  }
  return SystemInfo.deviceUniqueIdentifier;
 }

This ID is hashed and used in a simple XOR scheme to discourage local save tampering or sharing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using (SHA1 sha = new SHA1Managed())
  {
   array = sha.ComputeHash(Encoding.UTF8.GetBytes(GlobalStatic.GetUserID(false)));
  }
// Token: 0x06001B4E RID: 6990 RVA: 0x000A243C File Offset: 0x000A063C
 private byte[] decode(int offset, int size)
 {
  byte[] array = new byte[size];
  for (int i = 0; i < size; i++)
  {
   array[i] = this.SDQ[(int)this.FDA[i + offset]] ^ this.lDV[(int)this.FDA[i + offset]];
  }
  return array;
 }

 // Token: 0x06001B4F RID: 6991 RVA: 0x000A2484 File Offset: 0x000A0684
 private void encode(byte[] arr, int offset, int size)
 {
  for (int i = 0; i < size; i++)
  {
   this.SDQ[(int)this.FDA[i + offset]] = arr[i] ^ this.lDV[(int)this.FDA[i + offset]];
  }
  this.saveToPrefs(false);
 }

An unused hash test call reveals http://jrex.srj-studio.com/_sysio/itemshop/hasher.php, an attempt at HMAC in which it verifies for parity between client and server.

1
2
float res = (float)(hashed ^ lint) * 0.0001f;
Debug.Log("Remote int: " + res);

Notably, the savegame is decrypted client-side before being sent to the custom cloud service.

Anti-tamper measures

The server sends values to the client that are used for physics in gameplay. If the server values are unset (due to activation or cloud failure), gameplay physics become unstable. Notably, the values assmForceAll and assmForceHalf:

1
2
3
4
5
6
7
8
9
10
11
12
13
private IEnumerator LoadFromCloudServer(GlobalStatic.CallbackFunc callbackFunc_0)
 {
  WWW www;
  if (!(GlobalStatic.userTicket == string.Empty))
  {
   string url = "http://jrex.srj-studio.com/_sysio/scloud.php?userid=" + GlobalStatic.GetUserID(true);
      ...
    }

    this.encode(BitConverter.GetBytes(myCloud.assmForceAll), 24, 4);
    this.encode(BitConverter.GetBytes(myCloud.assmForceHalf), 28, 4);
    ...
 }

Which are then used for in-game physics. Without the proper values, the car will explode on repeat.

1
2
3
4
5
6
7
public class allPartsProcessing : CommonPartsProcessing
{
  ...
  this.x1u[l].breakForce *= GlobalStatic.CarConfig.assmForceHalf;
  this.x1u[l].breakTorque *= GlobalStatic.CarConfig.assmForceHalf;
  ...
}

Additionally, the undefined values will make Unity’s Joint system fail catrastrophically.

1
Joint::setBreakable: maxForce should be nonnegative!

You can get around this by assigning sane values to CarClass (debuggers will fire exceptions on null reads). The function decodeDefaultValues reveals that the server sent default values to populate the physics parameters in the client.

More research could be done on the original values. In short, the car’s physics parameters are SHA1-hashed with the user ID, XOR’d with a local table, and reordered before being saved. The classes are instanced from a base64 of the values via LoadClassFromString.

Licensing, states and why it softlocks

We can now piece together how the game obtains a license, and why a network failure results in a broken game.

Our fun begins with tracking down how the game checks for a license. It partially uses Steam’s ticket, but still requires their server to validate it.

flowchart TD
    A[Game Init] --> D{Licensed?}

    D -->|No| E[Set NewDataModel = 337]
    E --> F[HTTP GET userio.php]

    D -->|Yes| G{NewDataModel == 0?}
    G -->|Yes| F
    G -->|No| H[Skip activation]

    F --> M{Server reachable?}

    M -->|No| N[Activation UI stuck]
    M -->|Yes| O{SUCCESS?}

    O -->|Yes| P[UpdateToken<br/>Set licensed flag]
    O -->|No| Q[Show error message]

Traversing verifyPaidVersion(), we find activationProcess(). This function sends the Steam authentication ticket to http://jrex.srj-studio.com/_sysio/activation.php, which returns a status value stored in this.RDU.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private void Start()
{
  ...
  GlobalStatic.verifyPaidVersion(1);
}

public static int verifyPaidVersion(int phase)
{
 return GlobalStatic.cGg.verifyPaidVersion(phase);
}

private IEnumerator activationProcess()
 {
  WWW www;
  if (!(GlobalStatic.userTicket == string.Empty))
  {
   this.RDU = 0; // This is our "activation process" state
   string url = "http://jrex.srj-studio.com/_sysio/activation.php?token=" + GlobalStatic.userTicket;
   www = new WWW(url);
   yield return www;
  }
  ...
  return 1; // coroutine decompilation weirdness
  if (www.error == null)
  {
   goto IL_00BF;
  }
  Debug.LogError("ProtectedPad, verifyPaidVersion() ERROR: " + www.error);
  yield break;
  IL_00BF:
  if (!www.text.Contains("ERROR"))
  {
   goto IL_00E4;
  }
  this.RDU = -1; // Failed to verify. The code handles this in case of server error
  goto IL_0106;
  IL_00E4:
  this.decodeDefaultValues(www.text);
  this.RDU = 2; // License activated
  IL_0106:
  yield break;
 }

The RDU variable is reused throughout the game for multiple transactional operations. A global, mutable transaction state variable reused across unrelated asynchronous flows, with inconsistent success values and no formal state contract.

ValueActivation Flow MeaningItem Transaction MeaningNotes
-2Invalid tokenServer integrity mismatch
-1Activation failedGeneric failure (possible)Used as negative error
0Checking / pendingIn-progressSet before request
1Transaction successNot used for activation success
2Activation successActivation-specific success
>2Server-defined statusParsed directly from response

There is no unified success code. Activation considers >1 success, transactions consider 1 success.

This coroutine has no offline fallback. The activation UI waits for RDU to change from 0. On network failure, the coroutine exits early and RDU remains 0 indefinitely.

1
2
Debug.LogError("ProtectedPad, verifyPaidVersion() ERROR: " + www.error);
yield break;

It locks the game completely, never clearing the forced activation window. The free trial version is not accessible to the player either.

The game also attempts to recover a “licensed” save from their cloud system, but since the servers are down and it doesn’t use Steam Cloud, it can never recover from the softlock.

It is possible that older saves carry over the license state and avoid the softlock, provided they were created on later game versions. However, since it lacks Steam Cloud, uninstalling the game means forever losing the license state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (resultCode > 1)
   {
    if (PlayerPrefs.GetInt("NewDataModel", 0) == 336)
    {
     GlobalStatic.LoadFromCloud(new GlobalStatic.CallbackFunc(this.restoreFromCloudResult));
    }
    PlayerPrefs.SetInt("paidChecked", 1);
    ...
   }
   else
   {
    ...
    this.retryButton.gameObject.SetActive(true);
   }

Found backdoors

Internal code reveals a few interesting backdoors. Of note, a DOOM cheat code reference that unlocks your game to the full version, a reset license, and the “free trial” code. ItemID is related to car upgrades you can purchase (23 is dummy, inaccessible through the item shop).

flowchart LR
    A[serverCommand itemID 23] --> B{value == 337?}
    B -->|Yes| C[Set token = iddqd]
    C --> D[licensed = 1]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (itemID == 23 && (int)value == 337)
  {
   PlayerPrefs.SetString("token", "iddqd");
   PlayerPrefs.SetInt("licensed", 1);
  }

if (this.svW == "IDKFA")
  {
   PlayerPrefs.DeleteAll();
   Debug.Log("Free All Props!");
   this.licMessage.parent.parent.gameObject.SetActive(false);
  }
if (this.svW == "top50")
  {
  }

public void RequestFreeLicense()
 {
  if (!this.Yve)
  {
   this.Yve = true;
   this.svW = "IWANNAFREE";
   base.StartCoroutine(this.SendLicense());
  }
 }

Postmortem of implementation

Without diving yet on whether using such an implementation warranted use, let’s see what we could have done to better improve the current code.

Network resilience failure

The lack of proper fallbacks such as obtaining licenses from Steamworks, or license caches, means the game is stranded. Better timeout and retries/bouncing on network attempts and coroutines would have meant that even on exceptions, the user would not be stuck. Additionally, the UX would have greatly improved when the game was “alive”.

State management failure

This is possibly the “biggest” flaw in the design. States have several blind spots, reusing a global “mutex” for the transaction model, server state injecting anonymously and poorly defined values for RDU means the state management is in complete disarray. This makes testing and reasoning about client behavior significantly harder.

Allowing the server to inject loosely defined state values makes it difficult to correlate client behavior with specific API calls. The UI was defaulting to “waiting” on the transaction, when RDU does not even define a “wait” state, falling into undefined behavior.

Platform integration failure

When sold on Steam, there is an implicit expectation that Steam authentication alone is sufficient to access the purchased product.

The absence of Steam Cloud support removed a critical recovery mechanism for license state persistence.

What now?

As of posting this, you can still purchase this game. Out of over 1000 reviews (per SteamDB), many report complete inability to play. A new user can’t play it, uninstalling it means revoking your access, and any money spent on micro-transactions is gone too.

The last update was in 13 August 2016, per SteamDB, and the developer’s website http://srj-studio.com (which hosted the APIs) has been down since roughly 2021. They have since moved to https://real-dynamics.ru, but unfortunately the redirect no longer works.

Back in the early 2010s, there were not as many good solutions for cross-platform situations such as these. Ensuring whether the fallbacks work would have been a start, or a post-abandonment update removing the custom DRM entirely.

Preservation efforts towards projects like these are difficult, due to legal and grey areas. While intentions were good, architectural and implementation issues have stranded the product. The DRM did not fail because it was malicious. It failed because it was tightly coupled to infrastructure that had no end-of-life plan.

This analysis is intended for documentation and preservation purposes. No instructions for bypassing licensing are provided, and no modifications are distributed.

This post is licensed under CC BY 4.0 by the author.