Reverse engineering the North Korean version of a popular Sim City-like game using Ghidra and ndSpy to understand video game monetization strategies in the DPRK and the marketization of the country’s economy.
Key takeaways:
0. Introduction
1. Licensing system
2. File integrity checks
3. In-game monetization strategy and key generation
4. Conclusion
0. Introduction
During a recent trip to North Korea, I noticed the recent and ubiquitous presence of Information Technology Exchange Rooms (정보기술교류실), physical stores where one can purchase a variety of electronic devices – from laptops and tablets to USB sticks and chargers – as well as software and video games for PC, mobile and tablets (for an in-depth look at what goes on inside those stores as well as what the app selection looks like, this article by Alek Sigley provides a an excellent description. There are also a few videos on YouTube). After looking through the catalogue of available games at different stores, I eventually decided to try and buy a Sim City-like game called City Management (도시경경).
The game only cost 5000 wons (less than 1 USD) which I paid to have the app installed on the phone I had, a Samsung Galaxy A5 running Android 8. The vendor connected the phone to his PC, transferred the APK and tried to install it, but to no avail. After multiple attempts, he eventually informed me that North Korean apps most likely could not run on phones from other countries.
Fortunately, I was later able to purchase one of the different tablets sold in North Korea. I got the Morning (아침) brand, which is geared towards students and quite affordable. The tablet ran Android 4 (Kit Kat) on an ARM cpu and came loaded with a few educational apps: language learning courses, dictionaries and several e-book libraries containing the complete works of Kim Il Sung, school textbooks and a collection of literary works. No games, but that could now be fixed quite easily.
I retrieved the City Management APK from my phone and installed it on the tablet, where it ran perfectly. Unfortunately, after the game’s initial splash screen, I landed on this:
The screen tells us that there is no “key file” (열쇠화일) and that we should purchase one at a store. There is a “request number” (요청번호) likely used to generate the licence key and make sure it can’t be shared with other devices. Unfortunately, since the APK never installed, the vendor did not put a licence file on my phone when I bought the app. My stay in North Korea was coming to an end too and I did not have time to go back to an app store to buy a new key. So I figured I would take a look inside the app and see if I could get it running nonetheless.
1. Licensing system
To start looking into the APK’s code, I’ll use the standard suite of tools to decompress, decompile and rebuild android apps: dex2jar, jd-gui, apktool and apksign. I’ll also use Android Studio to run and debug the app. The fact that I couldn’t run the app on my phone may have just come from an Android version compatibility issue: I had no problem running it on an emulated Android 4.4 device with Android Studio. The decompilation of the classes.dex
file gives us some interesting information right away:
The name of the com.bz.cityisland2
package actually refers to the original game that City Management is based on: City Island 2 by the Dutch game studio Sparkling Society. The name of the package com.smartions.appprotected
refers to Smartions, a company that offers solutions to “monetize your mobile game or app in China” and are apparently also City Island’s distributor in China. There are no mentions of those companies in the game itself however. The game’s loading splash screen only tells us that the game was made by the Ryusong (meteor) Technology Exchange Center (류성기술교류소) and that it is protected by the law for the protection of software (콤퓨터쏘프트웨어보호법). The law has been in place since 2003 to regulate the sales and distribution of software in the country and guarantees software developers the private ownership of their creation.
It’s hard to tell whether the North Korean version is based on the source code of the original game or if it’s entirely reverse engineered. In any case, the North Korean version does not use Smartions’s monetization system nor Sparkling Society’s but relies on a different system, which is the main difference from the original game. Save for the translation and some minor renames, the game is otherwise similar to the original (from a cursory examination) in its design, gameplay, features… to the original.
There’s not much more we can glean from the Java code for now since, as the classes in unity3dplayer
and AndroidManifest.xml
file make clear, it is used to run code that was written with Unity, a popular cross-plaform video game framework which uses C# as its main programming language. The Unity code is stored in various library with the developer’s C# code being compiled to Assembly-CSharp.dll
. C# compiled code is easily decompilable using tools such as dnSpy. Once the dll is decompiled, we can look for the message we got earlier “열쇠화일이 존재하지 않습니다” (“The key file does not exist”) to find the bits of code we are interested in. The string search takes us to the CIGLoadingScreen
class where we find the string among other variables:
// Token: 0x0400056A RID: 1386 private string userKey; // Token: 0x0400056B RID: 1387 private string tapjoyCurrencyIdentifier; // Token: 0x0400056C RID: 1388 private bool bannerVisible; // Token: 0x0400056D RID: 1389 private int _loadingScreenShownCount; // Token: 0x0400056E RID: 1390 private Dictionary<int, bool> m_gameObjectStatus = new Dictionary<int, bool>(); // Token: 0x0400056F RID: 1391 private bool m_isVerify; // Token: 0x04000570 RID: 1392 private Font kfont; // Token: 0x04000571 RID: 1393 private string reqMsg = "열쇠화일이 존재하지 않습니다.\r\n열쇠화일을 판매소에서 구입하십시오."; // Token: 0x04000572 RID: 1394 private string reqNumLabel = "요청번호 : "; // Token: 0x04000573 RID: 1395 private string reqNum; // Token: 0x04000574 RID: 1396 private string finishLabel = "끝내기";
Looking for the name of the string variable reqMsg
takes us here:
// Token: 0x06000928 RID: 2344 RVA: 0x00026E60 File Offset: 0x00025060 private void OnGUI() { if (!this.m_isVerify && this.loadingDone) { GUI.skin.font = this.kfont; GUI.DrawTexture(new Rect(0f, 0f, (float)Screen.width, (float)Screen.height), this.blackBg, ScaleMode.StretchToFill); GUI.Label(this.GetTextLabelRect(this.reqMsg, 0.5f, 0.3f), this.reqMsg); GUI.Label(this.GetTextLabelRect(this.reqNumLabel, 0.3f, 0.5f), this.reqNumLabel); GUI.Label(this.GetTextLabelRect(this.reqNum, 0.6f, 0.5f), this.reqNum); RectOffset padding = GUI.skin.button.padding; GUI.skin.button.padding = new RectOffset(20, 20, 10, 10); if (GUI.Button(this.GetButtonRect(this.finishLabel, 0.5f, 0.8f), this.finishLabel)) { Application.Quit(); } } }
This is the code used to display the splashscreen we encountered earlier. If the boolean property this.m_isVerify
, presumably the result of a call to a function checking the existence and validity of a licence key, is False
then, the screen is displayed with the message we saw earlier and the “request number”. The verification function and the generation of the request number are handled in another class GameCus
:
using System; using System.IO; using System.Runtime.InteropServices; // Token: 0x02000147 RID: 327 public class GameCus { // Token: 0x06000AA7 RID: 2727 [DllImport("Game")] private static extern int vProcess(byte[] key, int keyLen, byte[] certData, int certDataLen); // Token: 0x06000AA9 RID: 2729 RVA: 0x0002E910 File Offset: 0x0002CB10 public string GetReqNumber() { string deviceIdString = this.GetDeviceIdString(); return string.Format("{0:d4} {1:d4} {2:d4} {3:d4}", new object[] { deviceIdString.Substring(0, 4), deviceIdString.Substring(4, 4), deviceIdString.Substring(8, 4), deviceIdString.Substring(12, 4) }); } // Token: 0x06000AAA RID: 2730 RVA: 0x0002E968 File Offset: 0x0002CB68 public string GetDeviceIdString() { string text = Utils.GetDeviceModel(); text = string.Format("{0:d10}", (uint)text.GetHashCode()); string str = text.Substring(2, 8); string text2 = Utils.GetDeviceUid(); text2 = string.Format("{0:d10}", (uint)text2.GetHashCode()); string str2 = text2.Substring(2, 8); return str + str2; } // Token: 0x06000AAB RID: 2731 RVA: 0x0002E9CC File Offset: 0x0002CBCC public bool checkCertData(byte[] certData) { if (certData == null || certData.Length == 0) { return false; } string text = this.GetDeviceIdString() + "-evXww9A+fJxc7IOv93ZMlvonEtE"; char[] array = text.ToCharArray(); byte[] array2 = new byte[array.Length]; for (int i = 0; i < array.Length; i++) { array2[i] = (byte)array[i]; } return GameCus.vProcess(array2, array2.Length, certData, certData.Length) == 4097; } // Token: 0x06000AAC RID: 2732 RVA: 0x0002EA30 File Offset: 0x0002CC30 public byte[] getCertData() { string keyFilePath = Utils.GetKeyFilePath("103107002.rsb"); if (keyFilePath == null) { return null; } byte[] result; try { if (!File.Exists(keyFilePath)) { result = null; } else { FileStream fileStream = File.Open(keyFilePath, FileMode.Open); byte[] array = new byte[fileStream.Length]; if ((long)fileStream.Read(array, 0, (int)fileStream.Length) != fileStream.Length) { result = null; } else { result = array; } } } catch (Exception ex) { ex.ToString(); result = null; } return result; } }
There are two things going on here, one being the generation of the request number and the other the verification of the licence key file.
The algorithm for the generation of the request number in GetReqNumber
and GetDeviceIdString
is fairly straightforward. We first get the device’s model name and the device’s unique id as two strings from calling two functions in the Utils
package (not detailed here). We get the hash code for each of these strings and convert it to its base 10 representation as a string. The string is padded with leading 0’s if the length of the number is smaller than 10. Then the 2nd to 9th characters of each string are concatenated and split into 4 space separated blocks of 4 characters each. The resulting string is the “request number” (요청번호) that we are supposed to give to the app store to get a licence key.
The licence key verification process is a bit more complicated. First, getCertData
will retrieve the licence key and read the data stored in it. We can see that the licence key’s filename is 103107002.rsb
but to find its exact location, we need to look into the Utils
package:
// Token: 0x060003FC RID: 1020 RVA: 0x000109FC File Offset: 0x0000EBFC public static string GetKeyFilePath(string keyFileName) { string result = null; using (AndroidJavaObject @static = new AndroidJavaClass("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity")) { using (AndroidJavaClass androidJavaClass = new AndroidJavaClass("com.unity3d.player.kk")) { object[] args = new object[] { @static, "/GameRyusong/" + keyFileName }; result = androidJavaClass.CallStatic<string>("a2", args); } } return result; }
Here the name of the key file is concatenated to the name of a directory called /GameRyusong/
and that string is sent to a function defined back in the Java part of the app. Going back to our decompiled Java code, we can find that function (a2
in the com.unity3d.player.kk
class). Unfortunately, the decompiler was unable to process the function and all we have is Java bytecode:
/* Error */ public static String a2(Context paramContext, String paramString) { // Byte code: // 0: iconst_0 // 1: istore_2 // 2: new 42 java/util/ArrayList // 5: dup // 6: invokespecial 43 java/util/ArrayList:<init> ()V // 9: astore_3 // 10: aload_3 // 11: invokestatic 49 android/os/Environment:getExternalStorageDirectory ()Ljava/io/File; // 14: invokevirtual 54 java/io/File:getAbsolutePath ()Ljava/lang/String; // 17: invokevirtual 58 java/util/ArrayList:add (Ljava/lang/Object;)Z // 20: pop // 21: new 60 java/lang/ProcessBuilder // 24: dup // 25: iconst_0 // 26: anewarray 62 java/lang/String // 29: invokespecial 65 java/lang/ProcessBuilder:<init> ([Ljava/lang/String;)V // 32: iconst_1 // 33: anewarray 62 java/lang/String // 36: dup // 37: iconst_0 // 38: ldc 67 // 40: aastore // 41: invokevirtual 71 java/lang/ProcessBuilder:command ([Ljava/lang/String;)Ljava/lang/ProcessBuilder; // 44: iconst_1 // 45: invokevirtual 75 java/lang/ProcessBuilder:redirectErrorStream (Z)Ljava/lang/ProcessBuilder; // 48: invokevirtual 79 java/lang/ProcessBuilder:start ()Ljava/lang/Process; // 51: astore 16 // 53: aload 16 // 55: astore 6 // 57: aload 6 // 59: invokevirtual 85 java/lang/Process:waitFor ()I // 62: pop // 63: aload 6 // 65: invokevirtual 89 java/lang/Process:getInputStream ()Ljava/io/InputStream; // 68: astore 19 // 70: sipush 1024 // 73: newarray <illegal type> // 75: astore 20 // 77: ldc 91 // 79: astore 9 // 81: aload 19 // 83: aload 20 // 85: invokevirtual 97 java/io/InputStream:read ([B)I // 88: iconst_m1 // 89: if_icmpne +48 -> 137 // 92: aload 19 // 94: invokevirtual 100 java/io/InputStream:close ()V // 97: aload 6 // 99: invokevirtual 103 java/lang/Process:destroy ()V // 102: aload 9 // 104: ldc 105 // 106: invokevirtual 109 java/lang/String:split (Ljava/lang/String;)[Ljava/lang/String; // 109: astore 10 // 111: iconst_0 // 112: istore 11 // 114: iload 11 // 116: aload 10 // 118: arraylength // 119: if_icmplt +93 -> 212 // 122: aload_3 // 123: invokevirtual 112 java/util/ArrayList:size ()I // 126: istore 15 // 128: iload_2 // 129: iload 15 // 131: if_icmplt +126 -> 257 // 134: ldc 91 // 136: areturn // 137: new 29 java/lang/StringBuilder // 140: dup // 141: aload 9 // 143: invokestatic 116 java/lang/String:valueOf (Ljava/lang/Object;)Ljava/lang/String; // 146: invokespecial 33 java/lang/StringBuilder:<init> (Ljava/lang/String;)V // 149: new 62 java/lang/String // 152: dup // 153: aload 20 // 155: invokespecial 119 java/lang/String:<init> ([B)V // 158: invokevirtual 37 java/lang/StringBuilder:append (Ljava/lang/String;)Ljava/lang/StringBuilder; // 161: invokevirtual 40 java/lang/StringBuilder:toString ()Ljava/lang/String; // 164: astore 21 // 166: aload 21 // 168: astore 9 // 170: goto -89 -> 81 // 173: astore 7 // 175: aconst_null // 176: astore 6 // 178: aload 7 // 180: astore 8 // 182: ldc 91 // 184: astore 9 // 186: aload 8 // 188: invokevirtual 122 java/lang/Exception:printStackTrace ()V // 191: aload 6 // 193: invokevirtual 103 java/lang/Process:destroy ()V // 196: goto -94 -> 102 // 199: astore 5 // 201: aconst_null // 202: astore 6 // 204: aload 6 // 206: invokevirtual 103 java/lang/Process:destroy ()V // 209: aload 5 // 211: athrow // 212: aload 10 // 214: iload 11 // 216: aaload // 217: ldc 124 // 219: invokevirtual 109 java/lang/String:split (Ljava/lang/String;)[Ljava/lang/String; // 222: astore 12 // 224: iconst_0 // 225: istore 13 // 227: iload 13 // 229: aload 12 // 231: arraylength // 232: if_icmplt +9 -> 241 // 235: iinc 11 1 // 238: goto -124 -> 114 // 241: aload_3 // 242: aload 12 // 244: iload 13 // 246: aaload // 247: invokevirtual 58 java/util/ArrayList:add (Ljava/lang/Object;)Z // 250: pop // 251: iinc 13 1 // 254: goto -27 -> 227 // 257: new 51 java/io/File // 260: dup // 261: new 29 java/lang/StringBuilder // 264: dup // 265: aload_3 // 266: iload_2 // 267: invokevirtual 128 java/util/ArrayList:get (I)Ljava/lang/Object; // 270: checkcast 62 java/lang/String // 273: invokestatic 116 java/lang/String:valueOf (Ljava/lang/Object;)Ljava/lang/String; // 276: invokespecial 33 java/lang/StringBuilder:<init> (Ljava/lang/String;)V // 279: aload_1 // 280: invokevirtual 37 java/lang/StringBuilder:append (Ljava/lang/String;)Ljava/lang/StringBuilder; // 283: invokevirtual 40 java/lang/StringBuilder:toString ()Ljava/lang/String; // 286: invokespecial 129 java/io/File:<init> (Ljava/lang/String;)V // 289: invokevirtual 133 java/io/File:exists ()Z // 292: ifeq +29 -> 321 // 295: new 29 java/lang/StringBuilder // 298: dup // 299: aload_3 // 300: iload_2 // 301: invokevirtual 128 java/util/ArrayList:get (I)Ljava/lang/Object; // 304: checkcast 62 java/lang/String // 307: invokestatic 116 java/lang/String:valueOf (Ljava/lang/Object;)Ljava/lang/String; // 310: invokespecial 33 java/lang/StringBuilder:<init> (Ljava/lang/String;)V // 313: aload_1 // 314: invokevirtual 37 java/lang/StringBuilder:append (Ljava/lang/String;)Ljava/lang/StringBuilder; // 317: invokevirtual 40 java/lang/StringBuilder:toString ()Ljava/lang/String; // 320: areturn // 321: iinc 2 1 // 324: goto -196 -> 128 // 327: astore 5 // 329: goto -125 -> 204 // 332: astore 17 // 334: aload 17 // 336: astore 8 // 338: ldc 91 // 340: astore 9 // 342: goto -156 -> 186 // 345: astore 8 // 347: goto -161 -> 186 // Local variable table: // start length slot name signature // 0 350 0 paramContext Context // 0 350 1 paramString String // 1 321 2 i int // 9 291 3 localArrayList java.util.ArrayList // 199 11 5 localObject1 Object // 327 1 5 localObject2 Object // 55 150 6 localProcess1 Process // 173 6 7 localException1 Exception // 180 157 8 localObject3 Object // 345 1 8 localException2 Exception // 79 262 9 localObject4 Object // 109 104 10 arrayOfString1 String[] // 112 124 11 j int // 222 21 12 arrayOfString2 String[] // 225 27 13 k int // 126 6 15 m int // 51 3 16 localProcess2 Process // 332 3 17 localException3 Exception // 68 25 19 localInputStream java.io.InputStream // 75 79 20 arrayOfByte byte[] // 164 3 21 str String // Exception table: // from to target type // 21 53 173 java/lang/Exception // 21 53 199 finally // 57 77 327 finally // 81 97 327 finally // 137 166 327 finally // 186 191 327 finally // 57 77 332 java/lang/Exception // 81 97 345 java/lang/Exception // 137 166 345 java/lang/Exception }
This isn’t the most readable bit of code, but we can at least surmise from it that it looks for the keyfile in the device’s primary shared storage directory and if it finds it, returns the absolute path of the keyfile (or 0 otherwise). The rest of getCertData
merely reads the key file’s contents and returns it as an array of bytes. That array of bytes can then be passed as an argument when calling checkCertData
.
checkCertData
takes our request number before formatting (i.e. without separating spaces), adds the trailing string “-evXww9A+fJxc7IOv93ZMlvonEtE” to it and sends the result along with the content of the licence key to a function called vProcess
. If the call to that function returns 4097, the licence key is valid. We now need to look into the vProcess
function, but that function is not part of our Unity C# code. Rather it is imported from an external library:
public class GameCus { // Token: 0x06000AA7 RID: 2727 [DllImport("Game")] private static extern int vProcess(byte[] key, int keyLen, byte[] certData, int certDataLen);
The library can be found as libGame.so
in the /lib
directory of the APK. This particular library is written in C++ so we can’t decompile it as easily as C#. Fortunately the NSA recently released its reverse engineering tool Ghidra which works great for disassembling binaries and even has an option to decompile to pseudo-C code. It won’t be as neat and readable as a C# decompile, but it can certainly help reading and understanding the assembly code (especially if you’re more familiar with x86 ASM than ARM ASM!):
************************************************************** * FUNCTION * ************************************************************** undefined vProcess() assume LRset = 0x0 assume TMode = 0x1 undefined r0:1 <RETURN> undefined4 Stack[-0x10]:4 local_10 XREF[1]: 000b3b58(W) vProcess XREF[2]: Entry Point(*), vInit:000b3b92(c) 000b3b50 13 b5 push { r0, r1, r4, lr } 000b3b52 04 4c ldr r4,[DAT_000b3b64] = 0002C2C0h 000b3b54 7c 44 add r4,pc 000b3b56 24 68 ldr r4,[r4,#0x0]=>->license_key = 000efe78 000b3b58 00 94 str r4=>license_key,[sp,#0x0]=>local_10 = 000b3b5a ff f7 8d ff bl generalProcess undefined generalProcess(undefin 000b3b5e 02 b0 add sp,#0x8 000b3b60 10 bd pop { r4, pc } 000b3b62 00 ?? 00h 000b3b63 bf ?? BFh DAT_000b3b64 XREF[1]: vProcess:000b3b52(R) 000b3b64 c0 c2 02 00 undefined4 0002C2C0h ? -> 0002c2c0
Here the dissassembly is enough and actually more instructive than the pseudo-C decompile (which merely renders the call to generalProcess();
and misses the license_key
argument). We load the value 0x2C2C0
from DAT_000b3b64
, add that value to the program counter (pc
) register, this gives us an address pointing license_key
which we load into r4
before calling generalProcess
. The license_key
value looks like this:
license_key XREF[3]: Entry Point(*), vProcess:000b3b58(*), 000dfe18(*) 000efe78 00 04 a3 undefine a0 75 53 92 1d ee 000efe78 00 undefined100h [0] XREF[3]: Entry Point(*), vProcess:000b3b58(*), 000dfe18(*) 000efe79 04 undefined104h [1] 000efe7a a3 undefined1A3h [2] 000efe7b a0 undefined1A0h [3] [...] 000efef2 da undefined1DAh [122] 000efef3 27 undefined127h [123] 000efef4 05 undefined105h [124] 000efef5 5a undefined15Ah [125] 000efef6 e6 undefined1E6h [126] 000efef7 4e undefined14Eh [127] 000efef8 c5 undefined1C5h [128] 000efef9 f7 undefined1F7h [129] 000efefa 00 undefined100h [130] 000efefb 00 undefined100h [131] 000efefc 00 undefined100h [132] [...] 000eff76 00 undefined100h [254] 000eff77 01 undefined101h [255] 000eff78 00 undefined100h [256] 000eff79 01 undefined101h [257]
To put it differently, license_key
is an array of 258 bytes. Towards the end of the array, after a long series of empty bytes, we find the value 0x10001
in the three final bytes. That value might seem familiar if you’ve worked with public-key encryption before: it is 65537 in base 10, a commonly chosen e
or public exponent for the RSA algorithm. The previous value could be a public key n
, which would be 1028 bits in size. Let’s see if we can validate that assumption by looking into the generalProcess
function.
Fortunately, Ghidra’s decompilation module works better here and offers a more readable output than plain ARM assembly:
generalProcess(undefined4 param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4, void *param_5) { int iVar1; undefined4 uVar2; int iVar3; int local_228; undefined4 local_224; undefined auStack544 [16]; undefined auStack528 [96]; undefined auStack432 [128]; undefined auStack304 [260]; int local_2c; local_2c = __stack_chk_guard; local_228 = 0; memcpy(auStack304,param_5,0x102); iVar1 = ReadBlock(auStack432,&local_224,0x80,param_3,param_4); if (iVar1 == 0) { iVar1 = R_VerifyInit(auStack528,5); if (iVar1 == 0) { iVar3 = iVar1; do { iVar1 = ReadUpdate(param_1,iVar3,param_2,auStack544,&local_228,0x10); if (iVar1 != 0) { iVar1 = R_VerifyFinal(auStack528,auStack432,local_224,auStack304); break; } iVar3 = iVar3 + local_228; iVar1 = R_VerifyUpdate(auStack528,auStack544); } while (iVar1 == 0); } } else { iVar1 = 0; } R_memset(auStack528,0,0x60); R_memset(auStack544,0,0x10); uVar2 = 0x1001; if (iVar1 != 0) { uVar2 = 0x2000; } if (local_2c == __stack_chk_guard) { return uVar2; } /* WARNING: Subroutine does not return */ __stack_chk_fail(); }
None of the functions we have here mention RSA explicitly, but a quick look inside R_VerifyFinal
reveals a call to an RSAPublicDecrypt
function. The function names and the code itself all look like they were taken from the RSAREF implementation of RSA released by RSA Labs in the 1990’s. Compare, for instance, the code above, with the sample signature verification function of RSAREF:
static void DoVerifyFile () { FILE *file; R_RSA_PUBLIC_KEY *publicKey; R_SIGNATURE_CTX context; int digestAlgorithm, status; unsigned char partIn[16], signature[MAX_SIGNATURE_LEN]; unsigned int partInLen, signatureLen; status = 0; if (ReadInit (&file, " Enter name of file to verify")) return; do { if (GetPublicKey (&publicKey)) break; if (GetDigestAlgorithm (&digestAlgorithm)) break; if (ReadBlock (signature, &signatureLen, sizeof (signature), " Enter filename of signature")) break; if ((status = R_VerifyInit (&context, digestAlgorithm)) != 0) break; while (!ReadUpdate (file, partIn, &partInLen, sizeof (partIn))) if ((status = R_VerifyUpdate (&context, partIn, partInLen)) != 0) break; if (status) break; if ((status = R_VerifyFinal (&context, signature, signatureLen, publicKey)) != 0) break; PrintMessage ("Signature verified."); } while (0); ReadFinal (file); if (status) PrintError ("verifying file", status); R_memset ((POINTER)&context, 0, sizeof (context)); R_memset ((POINTER)partIn, 0, sizeof (partIn)); }
In our case, R_VerifyInit
sets up a context and specifies MD5 as the hashing algorithm to use with the second argument, we then compute a digest by iterating over the data and updating the digest with R_VerifyUpdate
before verifying the signature with R_VerifyFinal
. If the signature is verified, the value 0x1001
(the 4097 that the C# code used to compare to the return value of the call to vProcess
) otherwise 0x2000
is returned.
Knowing that the implementation draws upon RSAREF also allows us to extract more information about the RSA keys: the license_key
is likely to be of the type R_RSA_PUBLIC_KEY
defined in RSAREF as follows:
typedef struct { unsigned int bits; /* length in bits of modulus */ unsigned char modulus[MAX_RSA_MODULUS_LEN]; /* modulus */ unsigned char exponent[MAX_RSA_MODULUS_LEN]; /* public exponent */ } R_RSA_PUBLIC_KEY;
So the first 4 bytes of the license_key
correspond to the length of the public key in bytes, i.e. 0x0400
or 1024 bits. The exponent, as we already know, is at the end and has a value of 65537. The private key then is what remains and its value is:
A3A07553921DEEAC5AC66EE02D3C0B3A130595D5BBE759DCF4D9D7F9D19F84CF2515F548B02730A8E55A089EB38D58253F15051FFA2A8EAD55BDFD978E71280E104BA4A21AA3DEB8318FC5A5DA9C5EF92FB1687B671524C33C8094ECC199AA8FD899A729578D433941BC6E3B6AA909AD6589D68213A32722DA27055AE64EC5F7
This is not particularly good news. A public key of 1024bits is currently still too large to be factorized by a single individual such as myself, unless the implementation is flawed or the pseudo-random number generator used to create the key present a weakness. The implementation used here does seem a bit dated. MD5 is not considered secure anymore. The RSAREF implementation is over 20 year old. Looking around the library, one notices that it also uses an old version of OpenSSL (OpenSSL 0.9.8g from October 2007). Interestingly, in addition to the commonly supported algorithms, the OpenSSL used here also ships with Jipsam and Pilsung, two North Korean private key algorithms described in further detail here. This tells us that we’re not dealing with just a translated version of the game, but that the whole licensing system was designed within the DPRK.
However, we know the message whose signature will be verified (the “request number” plus a suffix) and do not need to tamper with it, so finding MD5 collisions won’t be of any help. Several security weaknesses have been found in older version of OpenSSL, including with the PRNG, but there is no indication that it was used to generate the RSA keys. Indeed since the RSA signature verification function uses RSAREF, the same library was most likely also used to generate the keys. The RSAREF’s PRNG was actually audited by Bruce Schneier, but the vulnerabilities found (timing attacks) are of no help to us. Checking for basic vulnerabilities such as small or known factors using common attacks and tools like RsaCtf or factordb yields no results.
We are unable to factor the RSA public key to generate a licence for our device’s request number but we still have a few options if we want to play the game:
libGame.so
. For instance, we can change the private key to one we’ve generated and whose factor we know.generalProcess
function in libGame.so
so that it always returns 4097 and appears to validate the signature to the C# code.vProcess
.None of these options are particularly hard to implement. The first one would allow us to generate our own licence keys and would be more elegant but also more time-consuming, while the last two are trivial.
2. File integrity checks
I went with the last option of directly modifying the C# code in ndSpy as it is the most straightforward. We simply go back to the checkCertData
method of the GameCus
class and change the line:
return GameCus.vProcess(array2, array2.Length, certData, certData.Length) == 4097;
into:
return true;
before recompiling the dll with ndSpy.
We then rebuild the apk with apktool and sign it with signapk before installing it on our emulated Android device. The patch seems to have worked as instead of being asked for a licence key, we are taken to a screen with a button to start (시작) the game:
The game loads (정재중) for a while and just when we get a glimpse of the first level, it crashes. After running the game again in debug mode to try to catch the exception, Android Studio’s debugger breaks and tells us that the crash happened in the Unity part of the code while attempting to call a function named vInit
:
Looking for vInit
in the Unity libraries of the APK takes us to UnityEngine.dll
and the MonoBehaviour
class:
// Token: 0x02000044 RID: 68 public class MonoBehaviour : Behaviour { // Token: 0x06000316 RID: 790 [WrapperlessIcall] [MethodImpl(MethodImplOptions.InternalCall)] public extern MonoBehaviour(); // Token: 0x06000317 RID: 791 [DllImport("Game")] private static extern void vInit(string p, byte[] s, int l); // Token: 0x06000318 RID: 792 RVA: 0x000086D8 File Offset: 0x000068D8 public IEnumerator GetAutoCoroutine() { yield return null; ParameterizedThreadStart threadStart = delegate(object param) { string str = (string)param; string text2 = "ja"; text2 += "r:"; text2 += "fi"; text2 += "le://"; text2 += str; text2 += "!/a"; text2 += "ss"; text2 += "ets/"; text2 += "bi"; text2 += "n/Da"; text2 += "ta/Re"; text2 += "sou"; text2 += "rces/"; text2 += "uni"; text2 += "ty_bu"; text2 += "ilti"; text2 += "n_ext"; text2 += "ra"; string text3 = "bi"; text3 += "n/Dat"; text3 += "a/Ma"; text3 += "nag"; text3 += "ed/"; text3 += "Ass"; text3 += "emb"; text3 += "ly-"; text3 += "C"; text3 += "Sh"; text3 += "arp."; text3 += "d"; text3 += "ll"; WWW www = new WWW(text2); while (!www.isDone && www.error == null) { } if (www.error != null || www.bytes == null) { Application.Quit(); return; } byte[] bytes = www.bytes; if (bytes.Length > 128) { byte[] array = new byte[128]; Array.Copy(bytes, bytes.Length - 128, array, 0, 128); MonoBehaviour.vInit(text3, array, 128); return; } Application.Quit(); }; [...]
We notice too string variables being created by the recurrent concatenation of three character long blocks. Once reconstituted, the first one, text2
reads jar:file://{param_as_string}!/assets/bin/Data/Resources/unity_builtin_extra
and the second one, text3
gives us:
bin/Data/Managed/Assembly-CSharp.dll
Both are path to actually existing files that we can easily find within the decompressed APK directory. The content of unity_builtin_extra
is retrieved using Unity’s WWW class. If the file does not exist or is smaller than 128 bytes, the application exits (but does not throw an exception). The last 128 bytes of the file are then passed, along with text3
as arguments in a call to vInit
an external function that is imported from libGame.so
Back in Ghidra, the decompiler’s pseudo C code gives us a clearer look at what vInit
is doing:
void vInit(undefined4 uParm1,undefined4 uParm2,undefined4 uParm3) { undefined4 uVar1; undefined4 uVar2; undefined4 uVar3; int iVar4; uVar1 = AAssetManager_open(DAT_000f2a58,uParm1,0); uVar2 = AAsset_getLength(); uVar3 = AAsset_getBuffer(uVar1); iVar4 = vProcess(uVar3,uVar2,uParm2,uParm3); if (iVar4 != 0x1001) { uRam00000000 = 0; software_udf(0xff); } /* WARNING: Treating indirect jump as call */ (*(code *)0x42fdc)(uVar1); return; }
uParm1
contains the absolute path of Assembly-CSharp.dll
, the file is read and the resulting buffer is passed as an argument to vProcess
along with the buffer containing the last 128 bytes of the unity_builtin_extra
file (and their respective length). From our earlier analysis, we know that vProcess
is used to verify a RSA signature. In this case the function is there to ensure that the last 128 bytes of unity_builtin_extra
are the correct signature for Assembly-CSharp.dll
, i.e. to verify that the file has not been tampered with. If the signature is verified, vProcess
returns 0x1001
(4097), in which case the function safely returns. If the signature is not verified we end with the ARM instruction udf
which will throw an exception like the one we encountered earlier:
000b3b92 ff f7 dd ff bl vProcess 000b3b96 41 f2 01 03 movw r3,#0x1001 000b3b9a 98 42 cmp r0,r3 000b3b9c 02 d0 beq LAB_000b3ba4 // branch if vProcess returns 4097 000b3b9e 00 23 mov r3,#0x0 000b3ba0 1b 80 strh r3,[r3,#0x0] 000b3ba2 ff de udf #0xff // otherwise raise exception
So what is going on here is that the game tries to verify the integrity of Assembly-CSharp.dll
, most likely to prevent the types of modifications that we just did. It also makes sense that the strings containing the path to the files (text2
and text3
from earlier) would be reconstituted in such a cumbersome way. Rather than a decompilation artifact, this was a way to obfuscate the file integrity check: if someone tried to look for the string Assembly-CSharp.dll in the decompiled C# code to find where the file integrity check took place, the search would yield no results.
We still do not have a private key with which to resign our modified Assembly-CSharp.dll
, so the simple thing is merely to again go back to the C# code and prevent the call to vInit
from ever happening. Since the exception actually happens within the function, we do not have to worry about return values. We simply remove the following line:
MonoBehaviour.vInit(text3, array, 128);
We can now re-run the game. This time no exception is raised and the game start normally:
Note that if we had opted to modify the libGame.so
file directly, for example to change the RSA keys or make the vProcess
function always return 4097, we would have had to deal with another file integrity check, hidden in the UICamera class
:
using (AndroidJavaObject @static = new AndroidJavaClass("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity")) { using (AndroidJavaObject androidJavaObject = @static.Call<AndroidJavaObject>("getApplicationInfo", new object[0])) { string str = androidJavaObject.Get<string>("nativeLibraryDir"); string path = str + "/libGame.so"; using (FileStream fileStream = File.OpenRead(path)) { int num = (int)fileStream.Length; byte[] buffer = new byte[num]; fileStream.Read(buffer, 0, num); string s = "lgvhurFUrFf4sNgEIDTPLUbeeKhtaWOjlE9oQms4qGyStmieP8KPz4gcirWP/rXrsCdZmoyZ9Q9sbvhx/gBTLEQO36gIrxapV3LU6AoeiK3LlH99c1+avj0NOpznC/s/5dLLIbBdkgfPxsb3Mrt9VzZt31ruZj0xJAyWMLAj5hQ="; string s2 = "22y613nLMST8hxsPSWtpgLpuSnPIdhSVq8A+fWjdVc0AIvyOSsfoXS7sY25r1lN0Qy3i8r1NUECWjb4P87X+KQ9x6KrRSPGJj2THVXYoX5ul4HgQXpHABsBeDV7s6i84gVUKk83gfFxK7oanDSrLXfLKQgtim3PG5iqqd9gIgYc="; string s3 = "AQAB"; RSAParameters parameters = default(RSAParameters); parameters.Modulus = Convert.FromBase64String(s2); parameters.Exponent = Convert.FromBase64String(s3); RSACryptoServiceProvider rsacryptoServiceProvider = new RSACryptoServiceProvider(1024); rsacryptoServiceProvider.ImportParameters(parameters); if (!rsacryptoServiceProvider.VerifyData(buffer, new SHA1CryptoServiceProvider(), Convert.FromBase64String(s))) { Application.Quit(); } } } }
Here too a 1024 bit RSA signature scheme (with SHA1 instead of MD5 as hashing algorithm) is used to verify that the library file has not been tampered with (the implementation is done within Unity/C# rather than through vProcess
since its the library libGame.so
whose integrity we’re asserting). Trying to factor the public key is out of the question, but bypassing the check is trivial: one simply needs to reverse or delete the final conditional statement.
These file integrity checks present several interesting features: they are placed within unrelated, generic utility function and one of them uses a basic string obfuscation technique. This tells us that they are not simple routine checks made to ensure that the program would run smoothly but rather that they are intentionally hidden so as to be hard to find by people reverse engineering the game to either cheat or bypass the licencing system. This in turn suggests that the North Korean developers implementing the licencing system were actively trying to prevent the modding/cracking of the game, and that a modding/cracking scene exists in the DPRK.
One last file integrity check that I was unable to bypass is the one implemented by the tablet’s OS. While the patched and resigned APK runs fine on an Android emulator, it is impossible to install it on the tablet: the file is automatically deleted after being transferred. As has been reported previously on an analysis of another North Korean tablet presented at the CCC, the version of Android installed on DPRK tablets has a signature check in place for most file types: files should be digitally signed by the government or by the device itself. This mirrors the prescription of the law for the protection of software (콤퓨터쏘프트웨어보호법) mentioned earlier which states that all software, foreign or domestic, must be registered with the government before it can be used within the DPRK. The CCC talks detail a method for accessing the tablet’s system files, which would enable us to look further into the OS’s integrity. The method requires the purchase of some extra hardware to get the appropriate drivers and a bit of time. I haven’t had the chance to get into it so far, so I’ll skip this part for now and stick to the emulator for the rest.
3. In-game monetization strategy and key generation
One interesting thing I noticed after running the game and playing around with it was that the game retains the microtransaction strategy from the original game. Buildings, roads and other pieces of infrastructure come at a price (the better the building, the higher the price). The construction of every new building also takes some time, but it is possible to pay to speed up the delay.
All transactions within the game are done using one of two types of currency “Game Points” (유희점수) and “Game Treasure” (유희보물). These are called Cash and Gold in the original game — interestingly enough the cash icon has been changed from greenbacks to a more nondescript grey and the monetary connotation of the names has also been lessened in the North Korean translation. The virtual currency can be earned either within the game or purchased with real currency. On an Android device, a user willing to purchase more of the game’s currency in the original game will be redirected to Google Play to complete the transaction.
This purchase method, however, would be difficult to implement in North Korea. Few users have internet access and while electronic payment methods have become more common, they are not yet ubiquitous (not to mention that Google Play, or a similar platform, would have to support the cards).
What the North Korean game has instead is a serial number based system. Clicking on the “+” icon next to the cash and gold icons opens a popup window that informs you that for 2000 points, you can purchase 50 000 Game Points and 2 000 Game Treasures, or, for 5 000 points, you can purchase 150 000 Game Points and 6 000 Game Treasures. You are given a “request number” (요청번호) againt and there are textboxes that let you enter a serial number corresponding to that request number to complete the transaction.
The request number seems to change every time you open the window, meaning they can not be shared or reused and that they should be entered “on the spot”: you would have to go to your local app store, give the cashier your device, pay for the 2000 or 5000 option and the cashier would give you (or directly enter) the corresponding serial number to complete the purchase. The bogof-like sales promotion technique to push customers towards the more expensive package shows the attention to pricing strategies. The 2000 or 5000 “points” clearly correspond to a monetary amount in North Korean won although why the game uses the term “points” rather than directly mentioning “wons” remains unclear. It would be tempting to interpret it as unease over the mercantile nature of the transaction, but like in all socialist economies, there are plenty of shops and commercial transactions taking place everyday in North Korea.
I wanted to find out more about how the request numbers were generated and how the serial number verification might work, so I went back to dnSpy to look at the Unity code. Searching directly for “요청번호” or any of the other strings displayed on the popup does not yield any results. That is simply because most strings are not stored directly in the Unity dll but in a separate file (in /assets/AssetBundles/Korean.txt
) as variables with their Korean translation:
give_success = 구입이 성공하였습니다.
give_title = 유희점수 및 유희보물 구입
give_first_label = 2 000점으로 유희점수 50 000, 유희보물 2 000 구입
give_second_label = 5 000점으로 유희점수 150 000, 유희보물 6 000 구입
give_req_prefix = 요청번호 :
give_allow_default = 허가번호를 입력하십시오.
give_ok_first = 확인(2 000점)
give_ok_second = 확인(5 000점)
give_req_format_wrong = 요청번호형식이 정확하지 않습니다.\n개발자에게 문의하십시오.
give_allow_format_wrong = 허가번호형식이 정확하지 않습니다.\n판매소에 문의하십시오.
give_occur_error = 오유가 발생하였습니다.\n개발자에게 문의하십시오.
give_allow_wrong = 허가번호가 정확하지 않습니다.\n허가번호를 다시 입력하십시오.
Searching for those variable names in dnSpy takes us to the SocialPopupView
class. The class was likely used for social media sharing in the original game and, not serving any purpose on the North Korean market, was refurbished to accommodate the new licensing system.
// Token: 0x06000CE2 RID: 3298 RVA: 0x0003DB1C File Offset: 0x0003BD1C public void SetReqCode(string reqCode, bool first) { if (first) { this.mReqCode1 = reqCode; this.firstReqNumber.GetComponent<UILabel>().text = Localization.Get("give_req_prefix") + " " + this.mReqCode1; return; } this.mReqCode2 = reqCode; this.secondReqNumber.GetComponent<UILabel>().text = Localization.Get("give_req_prefix") + " " + this.mReqCode2; }
This is simply the code to display the request numbers, let’s now see how this.mReqCode1
and this.mReqCode2
are generated. When the popup window is opened, the setReqCode
function above is called with the result from a call to the getReqCode
method of the SocialPopupState
class:
// Token: 0x06000CDA RID: 3290 RVA: 0x0003D124 File Offset: 0x0003B324 public override void Open() { base.Open(); this.firstCodeInputBox.text = null; this.secondCodeInputBox.GetComponent<UIInput>().text = null; this.SetReqCode(((SocialPopupState)this.State).getReqCode(true), true); this.SetReqCode(((SocialPopupState)this.State).getReqCode(false), false); }
The code for getReqCode
is as follows:
// Token: 0x060006A3 RID: 1699 RVA: 0x000231E0 File Offset: 0x000213E0 public string getReqCode(bool firstReq) { long num = 0L; try { using (AndroidJavaClass androidJavaClass = new AndroidJavaClass("java.lang.System")) { num = androidJavaClass.CallStatic<long>("currentTimeMillis", new object[0]); } } catch (Exception) { return null; } string text = SystemInfo.deviceUniqueIdentifier; text += "citymanage-1.0.0"; long num2; if (firstReq) { num2 = 298234L; } else { num2 = 93457345L; } num2 *= num; text += num.ToString(); ulong num3 = (ulong)((long)text.GetHashCode()); string text2 = (num3 * (ulong)num2).ToString(); if (text2.Length < 12) { int num4 = 12 - text2.Length; for (int i = 0; i < num4; i++) { text2 += "0"; } } else { text2 = text2.Substring(0, 12); } string text3 = ""; for (int j = 0; j < text2.Length; j++) { text3 += text2[j]; if (j != 0 && (j + 1) % 4 == 0 && j != text2.Length - 1) { text3 += " "; } } return text3; }
The algorithm generates a string based on the device’s unique identifier, a constant string and the current time in milliseconds. The hash of this string is multiplied by the current time in milliseconds and a constant number whose value changes depending on whether the request number is for 2000 or 5000 points. The resulting number is converted to a string. If the string is longer than 12 characters, it is trimmed, if it is smaller it is padded with 0’s. The last loop formats the string into three blocks of four numbers separated by spaces.
This confirms our initial suspicion that the number were (pseudo-)randomly generated each time the popup window loads. The use of the device’s ID might be to prevent the sharing of serial numbers: in the unlikely event that the popup is loaded on two devices at the exact same time, the numbers generated would still differ.
Let’s go back to SocialPopupView
and see how the serial numbers are verified against those randomly generated request numbers. There are two relevant functions OnFirstBuyBtnClicked()
and OnFirstBuyBtnClicked()
and OnSecondBuyBtnClicked()
both of which do the same thing (maybe DRY is not a thing among North Korean engineers). OnFirstBuyBtnClicked()
simply verifies your serial number when you pay 2000 points and OnSecondBuyBtnClicked()
verifies your serial for 5000 points. Let’s look at OnSecondBuyBtnClicked()
:
// Token: 0x06000CE0 RID: 3296 RVA: 0x0003D908 File Offset: 0x0003BB08 private void OnSecondBuyBtnClicked() { string text = this.secondReqNumber.GetComponent<UILabel>().text; text = text.Remove(0, 7); text = text.Replace(" ", ""); ulong value; if (text.Length != 12 || !ulong.TryParse(text, out value)) { ((SocialPopupState)this.State).OpenInfoDialog(Localization.Get("give_req_format_wrong")); return; } string text2 = this.secondCodeInputBox.GetComponent<UIInput>().text; text2 = text2.Replace(" ", ""); ulong value2; if (text2.Length != 12 || !ulong.TryParse(text2, out value2)) { ((SocialPopupState)this.State).OpenInfoDialog(Localization.Get("give_allow_format_wrong")); return; } byte[] reqNum = Utils.UInt642ByteArray(value); Utils.UInt642ByteArray(value2); int num = SocialPopupView.vOffer(reqNum, text2, 5000); if (num == 2002) { ((SocialPopupState)this.State).OpenInfoDialog(Localization.Get("give_occur_error")); return; } if (num == 2003) { ((SocialPopupState)this.State).OpenInfoDialog(Localization.Get("give_allow_wrong")); return; } if (num == 1001) { bool suppressQueue = SingletonMonobehaviour<PopupManager>.Instance.SuppressQueue; SingletonMonobehaviour<PopupManager>.Instance.SuppressQueue = true; SingletonMonobehaviour<PopupManager>.Instance.CloseRecursive(); if (!suppressQueue) { SingletonMonobehaviour<PopupManager>.Instance.SuppressQueue = false; } SingletonMonobehaviour<PopupManager>.Instance.ShowInfoPopup("", Localization.Get("give_success")); CIGGameState ciggameState = SingletonMonobehaviour<GameState>.InstanceAs<CIGGameState>(); Currencies c = new Currencies("Gold", 6000m); Currencies c2 = new Currencies("Cash", 150000m); ciggameState.GiveCurrencies(c); ciggameState.GiveCurrencies(c2); ciggameState.SaveAll(); } }
The first few lines (lines 1-20 above) are pretty straightforward, the function simply gets the value of the request number and the inputted serial number, checks that they are 12 character longs after the spaces have been removed and checks that they are numeric. The most interesting part comes right after:
int num = SocialPopupView.vOffer(reqNum, text2, 5000);
This is the actual call to the serial number verification function, whose numerical return value will be saved in num
. The function will adopt different behaviors depending on that value. A value of 2002 will tell us that there is something wrong with the program and that we should contact the developers (오유가 발생하였습니다.\n개발자에게 문의하십시오). 2003 will tell us that our serial number is wrong (허가번호가 정확하지 않습니다.\n허가번호를 다시 입력하십시오.) and 1001 will increase our treasury by the requested amount of currencies. Here it would be possible to directly patch the C# code to always give us currency by more than the preconfigured amounts. But let’s keep on analyzing the verification process by looking into the call to vOffer
.
vOffer
takes three arguments, the first one, reqNum
is the request number after it has been stripped of spaces and converted to a byte array:
byte[] reqNum = Utils.UInt642ByteArray(value);
The second argument, text2
is simply the serial number we entered as a string. And the last argument is the number of “points” we paid, either 2000 or 5000. Finally, the function vOffer
itself is not internal to SocialPopupView
, it is an extern method that is imported from a library:
public class SocialPopupView : PopupBaseView { // Token: 0x06000CD8 RID: 3288 [DllImport("Game")] private static extern int vOffer(byte[] reqNum, string strAllowNum, int offerType);
The Game dll is the libGame.so library we encountered above, so to understand what is going on there we will have to ditch ndSpy for Ghidra again. The vOffer
function is easy to find in the Symbol Tree windows. Again we can look at the ARM Assembly code, or take advantage of Ghidra’s decompiling feature which will give us a slightly more readable C-syntax rendering of the Assembly code:
Ghidra’s analysis has detected the use of AES_set_encrypt_key
and AES_cbc_encrypt
which tells us that the function will use the Advanced Encryption Standard (AES) algorithm. Judging from the name of the functions, it is likely that the code is based on the AES implementation available as part of the OpenSSL toolkit. Using the documentation from OpenSSL for the AES functions and the “Rename Variable” feature of Ghidra, we can give the variables some more explicit names to make the code more readable:
undefined4 vOffer(uchar *req_num,char *entered_value,undefined4 operation_amount) { int aes_set_key_success; int strcmp_result; undefined4 return_error_code; AES_KEY aes_key; undefined4 final_key_to_compare; undefined4 uStack128; undefined4 uStack124; uchar aes_user_key [16]; uchar aes_iv [16]; undefined4 req_num_encrypted; undefined4 uStack80; undefined4 formatted_req_num_encrypted; undefined4 uStack64; undefined4 uStack60; int local_2c; local_2c = __stack_chk_guard; memset(aes_user_key,0,0x10); memset(aes_iv,0,0x10); strcpy((char *)aes_user_key,"cityManageoffer"); sprintf((char *)aes_iv,"initvector_%d",operation_amount); aes_set_key_success = AES_set_encrypt_key(aes_user_key,0x80,&aes_key); if (aes_set_key_success == 0) { memset(&req_num_encrypted,0,0x10); AES_cbc_encrypt(req_num,(uchar *)&req_num_encrypted,8,&aes_key,aes_iv,1); sprintf((char *)&formatted_req_num_encrypted,"%llu",req_num_encrypted,uStack80); memset(&final_key_to_compare,0,0xd); final_key_to_compare = formatted_req_num_encrypted; uStack128 = uStack64; uStack124 = uStack60; strcmp_result = strcmp((char *)&final_key_to_compare,entered_value); if (strcmp_result == 0) { return_error_code = 0x3e9; } else { return_error_code = 0x7d3; } } else { return_error_code = 0x7d2; } if (local_2c != __stack_chk_guard) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return return_error_code; }
To sum up, we initialize an AES context using the string "cityManageoffer"
as a secret key and the string “initvector_2000” (if we are paying 2000 points) or “”initvector_5000” (for 5000 points) as an Initialization Vector (IV). To be more precise, as both the secret key and IVs are 128 bits and the strings are only 15 characters long, the secret key and the IV are the aforementioned strings followed by a final empty byte (set with the call to memset
).
If the context initialization is not successful, we return an error code (see above). If it succeeds, we encrypt the request number (previously converted to an array of bytes in the C# code) using AES in CBC mode. The length
argument is set to 8, which seems a bit strange (AES should have a blocksize of 16, but maybe that is not what the argument is for. OpenSSL’s doc is not very detailed on this point). We then take the result of the encryption and convert it to its decimal numeric representation as a string using sprintf
. The final argument uStack80
would be out of a place in a call to the standard sprintf
without additional format arguments. It might be an artefact from Ghidra’s decompilation. The ARM Assembly code does load both r2
and r3
from two memory words before the call to sprintf
but this looks like it is simply a way to push the 64-bit unsigned long long req_num_encrypted
to sprintf rather than actually passing 2 separate arguments (I don’t know much about ARM Assembly, so this just a guess).
Once the string is printed, we create a buffer of 13 empty bytes and copy the result to it. We then compare that new string to the serial number we entered. Here too the decompiled code Ghidra gives us is a bit misleading:
memset(&final_key_to_compare,0,0xd); final_key_to_compare = formatted_req_num_encrypted; uStack128 = uStack64; uStack124 = uStack60; strcmp_result = strcmp((char *)&final_key_to_compare,entered_value);
formatted_req_num_encrypted
is the string representing a 64 bit-or-greater in base 10 after our call to sprintf
. Since it’s a > 64 bit number, it is likely that formatted_req_num_encrypted
is longer than 12 characters. If we run final_key_to_compare = formatted_req_num_encrypted;
, final_key_to_compare
will also be longer than 12 characters and the call to memset
would have been useless. Here again, the Assembly code gives us a better picture:
mov r1,r7 mov r2,#0xd mov r0,r5 blx memset ldm.w r6=>local_44,{r0, r1, r2 } stm r5=>local_84,{r0 r1, r2 } mov r0,r5 mov r1,r9 blx strcmp
After the call to memset
, we see that what Ghidra interprets as final_key_to_compare = formatted_req_num_encrypted;
is actually executed through the use of ldm.w
and stm
in assembly. ldm.w
will load three words from r6
(formatted_req_num_encrypted
) into r1
, r2
and r3
. This means we will only have the first three words, i.e. the first 3 blocks of 4 ascii characters, i.e. a 12 character long string. That string is then stored to the address in r5
with stm
, so the string we compare to the serial number we entered will indeed be only 12 character long (and terminated by a null byte thanks to memset
).
This is enough information to write a simple key generator in C:
#include <stdio.h> #include <string.h> #include <math.h> #include <openssl/aes.h> int main() { char req_num[12]; unsigned long long longInt; unsigned char final_key[12]; unsigned char aes_user_key [16]; unsigned char aes_iv [16]; unsigned long long req_num_encrypted; unsigned char formatted_req_num_encrypted [12]; int operation_amount = 0; AES_KEY aes_key; void LongToByteArray(unsigned char byteArray[8], unsigned long longInt) { int i; for (i=0; i < 8; i++) { byteArray[i] = (char)(longInt >> (i*8) & 0xFF); } } unsigned long StrToULong(char str[12]) { int len = 12; int i; unsigned long longInt; for (i=0; i<len; i++) { longInt += (str[len - i - 1] - 0x30) * pow(10, i); } return longInt; } while (strlen(req_num) != 12) { printf("Request number (12 characters, no spaces): "); scanf("%12s", req_num); } while (operation_amount != 5000 && operation_amount != 2000) { printf("Number of points (5000 or 2000): "); scanf("%d", &operation_amount); } longInt = StrToULong(req_num); unsigned char byteArray[8]; memset(byteArray, 0, 8); LongToByteArray(byteArray, longInt); memset(aes_user_key,0,0x10); memset(aes_iv,0,0x10); strcpy((char *)aes_user_key,"cityManageoffer"); sprintf((char *)aes_iv,"initvector_%d",operation_amount); AES_set_encrypt_key(aes_user_key,0x80,&aes_key); memset(&req_num_encrypted,0,0x10); AES_cbc_encrypt(byteArray,(unsigned char *)&req_num_encrypted,8,&aes_key,aes_iv,1); sprintf((char *)&formatted_req_num_encrypted,"%llu",req_num_encrypted); memset(&final_key,0,0xd); strncpy(final_key, formatted_req_num_encrypted, 12); printf("%s\n", final_key); return 0; }
And when we try out the result, the game informs us that the purchase was successful!
4. Conclusion
Looking inside the game revealed a number of interesting facts about the nascent North Korean video game industry. In this particular case, developers revamped a foreign game to give it Korean characteristics. This, however, does not mean that there is no domestic game or app industry: I have seen code for (free) games that were developed in the DPRK and met developers working on a variety of apps. I did not expect the game to have been made outside when I purchased it, in hindsight there were several games on sales that I can now tell must have been imports. But there were a number of others, such as historical games, that must have been made domestically. I will have to look into it next time I visit!