Skip to content
This repository has been archived by the owner on Oct 19, 2023. It is now read-only.

Latest commit

 

History

History
438 lines (374 loc) · 20.7 KB

File metadata and controls

438 lines (374 loc) · 20.7 KB

So, we want to figure out the encryption algorithm of the game KonoSuba: Fantastic Days, Japanese version. (As I missed documenting the global one).

KonoSuba itself is a Unity game, which can easily be seen by taking a look into the lib dir and noticing the libil2cpp.so. As that is the case, we can use Il2CppInspector to get some useful metadata for the following statical analysis of the game. For this analysis, we will use Ghidra.

For the correct setup of the Ghidra environment, follow these steps:

  1. run Il2CppInspector
  2. drop in the apk
  3. make export script via Python script for disassembler, script target: Ghidra, Namespaces: select all
  4. open Ghidra
  5. create a new project
  6. drop in the libil2cpp.sp and double click it
  7. DON'T START THE AUTO-ANALYSIS
  8. File -> Parse C Source
  9. clear profile, add the il2cpp.h generated by the Inspector, use the parse-options -D_GHIDRA_ and select Parse to Program
  10. open the script manager (white arrow within a green circle)
  11. manage script directories (between reload and help[red cross])
  12. add the directory where you save the output of the inspector to
  13. Filter: il2cpp.py and double click it to run it
  14. wait for ages, accept all errors, and if it gets stuck at Relocation Fixing (CPU load without any change in memory nor drive usage), cancel and start the script again
  15. Analysis -> analyze all open

And with that the work environment is set, now we can have fun looking for the encryption algorithm. As our main goal is the decryption, let's search for decrypt in the Symbol Tree A bunch of functions and labels show up, of which the most interesting is NetworkUtil_Decrypt. _ indicates that NetworkUtil is a class, so we select NetworkUtil_Decrypt and then remove the filter to see the whole NetworkUtil class.

Decrypt, Encrypt, ComputeHash, Bin2Hex, ToByte, this sure looks lively, and more important, custom. No normal library class would have such an assembly of functions. Considering this, we likely already found the encryption algorithm. Let's check the generated C code to the right side.

  this = (RijndaelManaged *)thunk_FUN_01645d4c(RijndaelManaged__TypeInfo);
  RijndaelManaged__ctor(this,(MethodInfo *)0x0);
  if (this == (RijndaelManaged *)0x0) {
                    /* WARNING: Subroutine does not return */
    FUN_01687b8c();
  }
  (*(this->klass->vtable).set_BlockSize.methodPtr)
            (this,0x80,(this->klass->vtable).set_BlockSize.method);
  (*(this->klass->vtable).set_KeySize.methodPtr)(this,0x80,(this->klass->vtable).set_KeySize.method)
  ;
  (*(this->klass->vtable).set_Mode.methodPtr)(this,1,(this->klass->vtable).set_Mode.method);
  (*(this->klass->vtable).set_Padding.methodPtr)(this,2,(this->klass->vtable).set_Padding.method);
                    /* try { // try from 026e62a4 to 026e62af has its CatchHandler @ 026e6490 */
  (*(this->klass->vtable).set_Key.methodPtr)(this,key,(this->klass->vtable).set_Key.method);
                    /* try { // try from 026e62b8 to 026e62c3 has its CatchHandler @ 026e648c */
  (*(this->klass->vtable).set_IV.methodPtr)(this,iv,(this->klass->vtable).set_IV.method);
                    /* try { // try from 026e62d0 to 026e62d7 has its CatchHandler @ 026e6488 */
  plVar2 = (long *)(*(this->klass->vtable).CreateDecryptor.methodPtr)
                             (this,(this->klass->vtable).CreateDecryptor.method);

Apparently the standard C# RijndaelManaged is used, so we can simply look it up to figure out the mode and padding.

  • Mode: 1 -> CBC
  • Padding: 2 -> PKCS7
  • BlockSize: 0x80 = 128
  • KeySize: 0x80 = 128
  • Key / IV come from the input

With this, we already figured out, all that is left is finding how the key and iv are generated. To do this we check where the Decrypt function is used by right-clicking on it, Show References to.

Looks like the function is only called once, so, we probably won't have to face that much trouble now. We simply click on the entry with the UNCONDITIONAL CALL context to see the function that uses the decrypt function. The function we are now in is named RequestBase_DecodeEncryptedJsonResponse, well well, that looks about right for what we're looking for. Anyway, back to the function itself, namely, where the decrypt function is called.

    pBVar12 = NetworkUtil_Decrypt(data,key,pBVar12,(MethodInfo *)0x0);
    pEVar4 = Encoding_get_UTF8((MethodInfo *)0x0);
    if (pEVar4 == (Encoding *)0x0) goto LAB_02c05d80;
    pSVar2 = (String *)
             (*(pEVar4->klass->vtable).GetString.methodPtr)
                       (pEVar4,pBVar12,(pEVar4->klass->vtable).GetString.method);
    uVar5 = (***(code ***)(method->Il2CppVariant + 8))(this,res,pSVar2,0,&local_78);

First of all, what's easy to see is, that the decrypted output is UTF8 encoded, which means, it's a text. Next up we look at the Decrypt call and see, that most used variables are already correctly named. data is data, key is key, and uhm, pBVar12 is the iv. As we want to figure out the key and iv, let's backtrack them to figure out how they are generated.

    pOVar13 = TextUtil__TypeInfo->static_fields->_ih_k__BackingField;
    if (((*(byte *)&(ObscuredString__TypeInfo->_1).field_0x67 >> 1 & 1) != 0) &&
       ((ObscuredString__TypeInfo->_1).cctor_finished == 0)) {
      thunk_FUN_0163e6f4(ObscuredString__TypeInfo);
    }
                    /* try { // try from 02c05b0c to 02c05b17 has its CatchHandler @ 02c05dd4 */
    pSVar2 = ObscuredString_op_Implicit_1(pOVar13,(MethodInfo *)0x0);
    if (DAT_05c69c95 == '\0') {
                    /* try { // try from 02c05b34 to 02c05b57 has its CatchHandler @ 02c05dbc */
      thunk_FUN_01644ab0(0xf693);
      DAT_05c69c95 = '\x01';
    }
    if (((*(byte *)&(TextUtil__TypeInfo->_1).field_0x67 >> 1 & 1) != 0) &&
       ((TextUtil__TypeInfo->_1).cctor_finished == 0)) {
      thunk_FUN_0163e6f4();
    }
                    /* try { // try from 02c05b64 to 02c05b6f has its CatchHandler @ 02c05dd0 */
    pSVar3 = ObscuredString_op_Implicit_1
                       (TextUtil__TypeInfo->static_fields->_ln_k__BackingField,(MethodInfo *)0x0);
                    /* try { // try from 02c05b70 to 02c05b9f has its CatchHandler @ 02c05ddc */
    pSVar2 = String_Concat_4(pSVar2,pSVar3,(MethodInfo *)0x0);
    if (((*(byte *)&(Convert__TypeInfo->_1).field_0x67 >> 1 & 1) != 0) &&
       ((Convert__TypeInfo->_1).cctor_finished == 0)) {
      thunk_FUN_0163e6f4();
    }
                    /* try { // try from 02c05ba0 to 02c05bab has its CatchHandler @ 02c05dcc */
    key = Convert_FromBase64String(pSVar2,(MethodInfo *)0x0);

That's quite some jada-jada with some try/catch and error handling in between, so let's remove them to get a better picture of the general code behavior.

    pOVar13 = TextUtil__TypeInfo->static_fields->_ih_k__BackingField;
    pSVar2 = ObscuredString_op_Implicit_1(pOVar13,(MethodInfo *)0x0);
    pSVar3 = ObscuredString_op_Implicit_1
                       (TextUtil__TypeInfo->static_fields->_ln_k__BackingField,(MethodInfo *)0x0);
    pSVar2 = String_Concat_4(pSVar2,pSVar3,(MethodInfo *)0x0);
    key = Convert_FromBase64String(pSVar2,(MethodInfo *)0x0);

So, our key is first of all...apparently static....which is quite a bad style, but good for us. Next up, it's stored as a base64 string which is split into two parts. Both of these parts are apparently some kind of obscured string that is stored in a global static field. The obfuscation part sounds like trouble, but before we get annoyed for real, let's check where these static fields get their values. For this we right-click on them, References -> Find uses of .. and wait a bit for the search to finish.

According to the search results, the field is only assigned once, so let's check the code segment where the assignment happens.

  pOVar1 = ObscuredString_op_Implicit(StringLiteral_t_FYtIo,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_vq_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral_Ul8VAaw_iEWq,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_hj_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral_wPH5VJzZ2g__,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_va_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral__9b262FeaZlW0,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_po_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral__1th53iN4vQ__,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_na_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral_Oj4oXTMPgZZb,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_zr_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral_UmAWGE7xcQ__,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_mb_k__BackingField = pOVar1;
  pOVar1 = ObscuredString_op_Implicit(StringLiteral_cN1XsiiYqd0x,(MethodInfo *)0x0);
  TextUtil__TypeInfo->static_fields->_ih_k__BackingField = pOVar1;

Hm, as we can see, each field is an obscured string version of a string literal. As string literals are constants, we can simply double click on them to check their value.

The value is a normal string, so let's, first of all, collect all various strings for each static field and see if they work.

This work is done left to the reader as an exercise.

    key = FromBase64( TextUtil__TypeInfo->static_fields->_ih_k__BackingField + 
        extUtil__TypeInfo->static_fields->_ln_k__BackingField )

When looking up the iv, we notice the iv depends on the uk value of a JWT-header.

    if (local_68 == (JwtApplicationHeader_Payload *)0x0) {
                    /* WARNING: Subroutine does not return */
                    /* try { // try from 02c05d84 to 02c05d87 has its CatchHandler @ 02c05d90 */
      FUN_01687b8c();
    }
                    /* try { // try from 02c057dc to 02c057e3 has its CatchHandler @ 02c05dd8 */
    bVar1 = String_IsNullOrEmpty(local_68->uk,(MethodInfo *)0x0);
    if ((bVar1 & 1U) == 0) {

If the uk value is set in the JWT-header, then

      if (local_68 == (JwtApplicationHeader_Payload *)0x0) {
                    /* WARNING: Subroutine does not return */
                    /* try { // try from 02c05d88 to 02c05d8b has its CatchHandler @ 02c05d8c */
        FUN_01687b8c();
      }
      pSVar2 = local_68->uk;
      if (((*(byte *)&(NetworkUtil__TypeInfo->_1).field_0x67 >> 1 & 1) != 0) &&
         ((NetworkUtil__TypeInfo->_1).cctor_finished == 0)) {
                    /* try { // try from 02c05a64 to 02c05a67 has its CatchHandler @ 02c05d94 */
        thunk_FUN_0163e6f4();
      }
                    /* try { // try from 02c05a68 to 02c05a77 has its CatchHandler @ 02c05db8 */
      pBVar12 = NetworkUtil_Hex2Bin(pSVar2,0x10,(MethodInfo *)0x0);

this uk value is converted from a hex string into binary and then used as iv. Otherwise, the iv is generated like the key from static fields.

    iv = FromBase64( TextUtil__TypeInfo->static_fields->_ez_k__BackingField
        TextUtil__TypeInfo->static_fields->_uo_k__BackingField )

So, now we know how to decrypt the payload and in turn also how to encrypt it for requests. Sadly this isn't enough yet, as to make requests, e.g. to fetch the latest masterdata, we have to be able to create a valid JWT-header so that the server accepts our requests. As the JWT-header includes an encrypted hash, we have to figure out how that hash is calculated as well as the key for the encryption. To do so, we simply look up the class that handles the whole jwt stuff, JwtApplicationHeader.

void JwtApplicationHeader__ctor
               (JwtApplicationHeader *this,String *checksum,String *userKey,MethodInfo *method)

The init function of the class shows us, that a checksum, as well as a userKey, is passed to it.

void JwtApplicationHeader_Payload__ctor
               (JwtApplicationHeader_Payload *this,String *cs,String *uk,MethodInfo *method)
{
  Object__ctor((Object *)this,(MethodInfo *)0x0);
  this->cs = cs;
  this->uk = uk;
  return;
}

The payload of the jwt header itself can consist of two values, cs - checksum and uk - userkey. Seeing the uk here, we are reminded of the earlier decoding function, where that value was used as well. So apparently once the client has an userkey, this userkey will be used as init vector instead of the default init vector.

While we know now about this userkey, we still have to figure what checksum is passed to the jwt header, so we have to check where the function is called to figure out what kind of checksum is passed. We do so the same way as we did with the Decode function earlier.

    pBVar3 = (Byte__Array *)this;
    ...
    pBVar3 = NetworkUtil_ComputeHash(pBVar3,(MethodInfo *)0x0);
    pSVar5 = NetworkUtil_Bin2Hex(pBVar3,(MethodInfo *)0x0);
    this_01 = (JwtApplicationHeader *)thunk_FUN_01645d4c(JwtApplicationHeader__TypeInfo);
    JwtApplicationHeader__ctor(this_01,pSVar5,(String *)this,(MethodInfo *)0x0);

As we can see, the hash is calculated in a class we already know. Taking a look at NetworkUtil_ComputeHash we see, that the md5 of the content is calculated. The md5-hash is afterward converted from bytes to a hex string. Now that we clarified how that checksum is calculated, all that is missing is how checksum of the jwt header itself is calculated.

  pSVar2 = JsonWebToken_Encode(this_00,(Object *)key,JwtHashAlgorithm__Enum_HS256,(MethodInfo *)0x0)
  ;
  this->_token = pSVar2;

Those two lines tell use, that HS256 (Sha256) is used as the hash algorithm of the token payload.

Further down the __ctor__ function we can find the key.

  value = TextUtil__TypeInfo->static_fields->_zr_k__BackingField;
  pSVar2 = ObscuredString_op_Implicit_1(value);
  str1 = ObscuredString_op_Implicit_1
                   (TextUtil__TypeInfo->static_fields->_mb_k__BackingField);
  pSVar2 = String_Concat_4(pSVar2,str1);
  key = Convert_FromBase64String(pSVar2);

And apparently, it's generated the same way as the ones from the real payload encryption itself, so we simply have to do the same as earlier to figure this key out as well.

With that we should know the whole encryption and authentication algorithm now, so we simply have to write our own code now to replicate the found behavior.

Let's start with converting the static fields into aes key and aes iv.

# we start with copying the values of the used backing fields
class static_fields:
    # iv
    _ez_k__BackingField = "Left"
    _uo_k__BackingField = "for"
    # key
    _ih_k__BackingField = "the"
    _ln_k__BackingField = "reader"
    # jwt key
    _zr_k__BackingField = "as"
    _mb_k__BackingField = "exercise"
# and then simply parse and assign them to global values for later use
aes_key = b64decode(
    static_fields._ih_k__BackingField + static_fields._ln_k__BackingField
)
aes_iv = b64decode(
    static_fields._ez_k__BackingField + static_fields._uo_k__BackingField
)
jwt_key = b64decode(
    static_fields._zr_k__BackingField + static_fields._mb_k__BackingField
)

Since we have to know the message structure of the messages first, we have to start with decrypting them before we can even dream about sending our own requests.

    # since we have to keep the userkey/uk around,
    # let's simply use a class
    def decrypt_request_data(self, request):
        # first we figure out the init vector for the content decryption
        # let's use the default for now
        iv = aes_iv
        # and check if a jwt header exists
        # and if it contains an userkey that has to be used as iv
        app_header = request.headers.get("X-Application-Header", None)
        if app_header: # get and if to handle cases where the header might be missing
            # the header apparently exists, so we will parse it now
            header, payload, signature = list(map(base64url_decode, token.split(".")))
            payload = json.loads(payload)
            # We can skip the signature check, 
            # as we don't have any real need for it ourselves, unlike the game.
            # Let's check if the jwt payload contains the userkey
            if payload.get("uk", None):
                # apparently it does, so we will have to use it
                # and parse it the same way as the game,
                # so we have to convert it as hexstring into bytes
                self.user_key = payload["uk"]
                iv = binascii.unhexlify(payload["uk"])
                # as the iv has to have a length of 128 bits / 16 bytes
                # we have to pad it with zeroes if it's too short
                iv = b"\x00" * (0x10 - len(iv)) + iv
        
        # not much to say about the key, we simply have to use the default value
        key = aes_key
        # as we've seen earlier, AES CBC is used
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decoded = cipher.decrypt(request.content)
        # in combination with pkcs7 padding
        return unpad(decoded, 0x10, "pkcs7")

def base64url_decode(input: Union[str, bytes]) -> bytes:
    if isinstance(input, str):
        input = input.encode("ascii")

    rem = len(input) % 4

    if rem > 0:
        input += b"=" * (4 - rem)

    return base64.urlsafe_b64decode(input)

Let's test the decryption function on some captured messages.

TODO

Looks like we're lucky and everything worked out fine, so we can now start with generating requests on our own. Unlike the decryption of requests, we now also have to take care of forging a real signature, so it will be a bit trickier.

    def request(self, path: str, body: dict):
        data = b""
        if body:
          # Let's start with the most simple task, encoding and encrypting the payload.
          # since the requests are x-form (not url) encoded,
          # we first have to convert our request body into a valid x-form format
          # which is simply key=value & key2=value2 & ...
          data = "&".join("=".join(item) for item in body.items()).encode("utf8")

      # the encryption uses the same arguments as the decryption,
      # so not much to say here
      iv = aes_iv
      if self.user_key:
          iv = binascii.unhexlify(self.user_key)
          iv = b"\x00" * (0x10 - len(iv)) + iv

      # key
      key = aes_key

      # encode data
      cipher = AES.new(key, AES.MODE_CBC, iv)
      data = cipher.encrypt(pad(data, 0x10, "pkcs7"))

    # Now we have to face the more annoying part, forging the authentification.
    # Let's start with the jwt payload, as it's quite simple - 
    # just the checksum of our encrypted payload.
    payload = {"cs": binascii.hexlify(md5(data).digest()).decode()}
    if self.user_key:
        # and add our userkey if we have one.
        payload["uk"] = self.user_key

    
    # Next up is the jwt header.
    header = {"alg": "HS256"}

    # And at long last, the signature calculation.
    # The signature itself is an hmac of the json encoded header and payload.
    # There is a nice lib that we could use for that
    # https://github.com/jpadilla/pyjwt
    # but sadly it adds another key: value to the jwt header by itself.
    # jwt tokens generated with such a header aren't accepted,
    # so we have to take the code from the library that we need
    # and modify that one part.

    # payload -> string
    json_payload = json.dumps(payload, separators=(",", ":")).encode("utf-8")
    # header -> string
    json_header = json.dumps(header, separators=(",", ":")).encode()

    segments = [base64url_encode(json_header), base64url_encode(json_payload)]

    # as mentioned earlier, the hmac signature is based on header and payload
    signing_input = b".".join(segments)
    # and uses sha256
    signature = hmac.new(key, msg=signing_input, digestmod=hashlib.sha256).digest()
    # now we append it to our segments
    segments.append(base64url_encode(signature))
    # and put it all together to generate a valid jwt token
    self.session.headers["X-Application-Header"] = b".".join(segments).decode("utf8")
    # Finally we're done with encryption and authentification,
    # so we can send the request to the server.
    url = f"{self.api}{path}"
    if self.user_no:
        url += f"?u={self.user_no}"
    self.session.headers["Content-Length"] = str(len(data))
    res = self.session.post(url, data=data)

    # 4. decrypt
    data = self.decrypt_request_data(res)

    return json.loads(data)

def base64url_encode(input: bytes) -> bytes:
    return base64.urlsafe_b64encode(input).replace(b"=", b"")