How I’m keeping code execution in the most secured mobile game – reverse engineering – Supercell part 8

There is no more doubt! After being involved with a lot of reverse engineering during the past year (banking, government applications and games) I can tell for sure that Supercell games are the ones with the most advanced anti cracking/tampering detection on the market. This, mostly thanks to the commercial compiler being used, but also thanks to the effort of the security team which keeps adding tricks to prevent everything said in the past 7 parts.

Welcome

to the 8th part of this awesome journey, in which I’m going to speak about my past months researchers on Supercell games. Those researchers are kept up even with the good partnership my company have with Supercell, mostly because we are running tools that still requires some alteration of the game code.

Down the business

we will find too much things. But the first thing we will meet is the crash of the game if we are running the frida server. This is because of the frida checks from the commercial compiler.

The compiler recently introduced 2 frida checks: hard check and soft check. Both of the checks relys on socket, leaving a safe door open for frida gadgets and injector.

Cracking the hard check

The hard check happens in dt_init_array where we know the compiler is doing most of it’s things including data decryption and crc setup. The hard check is running on a separate thread and so, is the unique one triggering pthread_create during the whole initialization array execution. The hard check is basically connecting to all the local ports, sending the standard frida authentication messages and check the response. The hard check can’t be nopped at start as later there are checks for a flag. What I’m doing is basically replacing a nested sub which is still on top of the check and return 0.

var res = Memory.scanSync(module.base, module.size, 'FC 6F BA A9 FA 67 01 A9 F8 5F 02 A9 F6 57 03 A9 ' +
			'F4 4F 04 A9 FD 7B 05 A9 FD 43 01 91 FF 0F 40 D1 FF 43 1F D1 F4 0F');
if (res.length > 0) {
	fridaHardCheckPtr = res[0].address;
} else {
	console.log('failed to find frida hard check ptr');
	return;
}

Interceptor.replace(fridaHardCheckPtr, new NativeCallback(function () {
	console.log('[*] frida hard check');
	Interceptor.revert(fridaHardCheckPtr);
	return 0;
}, 'int', ['int', 'int']));

Cracking the soft check

The soft check is triggered multiple times during the game execution. Similar to the hard check, the soft one is going to try to connect to the standard frida server port 27042 and will try to send the auth. What i’m doing here is find the inline syscall instruction and change the port in the sock addr struct.

var res = Memory.scanSync(module.base, module.size, '08 19 80 D2 01 00 00 D4 E9 03 00 2A E9 B3');
if (res.length > 0) {
	fridaSocketCheckPtr = res[0].address;
} else {
	console.log('failed to find frida socket check ptr');
	return;
}

Interceptor.attach(fridaSocketCheckPtr, function () {
	console.log('[*] frida socket check');
	this.context['x1'].writeByteArray([0x2, 0x0, 0xff, 0xff]);
});

Win code execution

So we want code execution now because our final goal is to proxy that game. In the frameworks i’ve created for my company, I’m making the game connect to a socket in an Android application running in background in the same device and so I’m replacing getaddrinfo.

var lop = Interceptor.attach(Module.findExportByName(null, 'getaddrinfo'), {
    onEnter: function() {
        this.path = this.context.x0.readUtf8String();
        if (this.path === 'game.clashroyaleapp.com') {
            log('[+] replacing host');
            this.context.x0.writeUtf8String('127.0.0.1');
            lop.detach();
        }
    }
});

There are 2 things to face after. First, there are some checks on the localhost address before calling socket syscall. It will basically check that the first value of the returned ip from getaddrinfo is not any of 127, 192, 10 and 0. Here i’m just attaching to the CMP instruction and alter the register value when CMP with 127 happens.

var intl = Interceptor.attach(base.add(0x4ccc6c), function() {
    this.context.x9 = 0x22;
    intl.detach();
});

The second thing is that the crc is triggered between socket connection and first send. (send is as usual achieved through inline syscall instruction). The tutorial trampoline is what we need to bypass this.

var op = Interceptor.attach(Module.findExportByName(null, 'open'), function (args) {
     var w = args[0].readUtf8String();
     if (w.indexOf('tutorial.sc') >= 0) {
        console.log('[+] tutorial kazam')
        op.detach();
        // execute whatever code at this point.
    }
});

At this point we miss 2 things to achieve proxy. The client private key (which is built in runtime by the game every run) and the server public key which is hashed into the game code. Those 2 keys needs somehow to be in the proxy application running on the same device. For the server public way we just needs to reverse engineer it once and hardcode the final one into the framework. To do so, I’m using those steps:

  1. The key is un-hashed during runtime. To do so, the code switch 15 cases. This code is repeated at least 5/6 times and a different jump table with the same code is created for each function. To find it out, it’s enough to list all the jump tables and check the ones with 15 switch cases. 90% of jump tables with 15 switch cases are pointing to the right one.
  2. The hasher move the hardcoded short value to register x9, it do maths and copy the result to the stack to build up the 32 bytes key. What we do will be simply to search in the whole library space the opcode of the instruction moving the 15th value into the stack, attach to all the results and trace the execution
var res = Memory.scanSync(m.base, m.size, '69 04 96 52');
res.forEach(function(r) {
    Interceptor.attach(r.address, function () {
        console.log('hit ' + this.context.pc.sub(m.base));
    });
});
var op = Interceptor.attach(base.add(0x2475a4), function () {
    op.detach();
    Interceptor['flush']();

    Stalker.follow(Process.getCurrentThreadId(),  {
        transform: function (iterator) {
            var instruction;

            while ((instruction = iterator.next()) !== null) {
                iterator.keep();

                if (instruction.toString() === 'str w9, [x0, #0x5d8]') {
                    iterator.putCallout(function (context) {
                        if (context.x9.toString() === '0xf') {
                            var key = context.x10.add(context.x11.shl(3)).sub(120);
                            key = ba2hex(key.readByteArray(128));
                            key = replaceAll(key, ' ', '');
                            key = replaceAll(key, '0000', '');
                            console.log(key);
                        }
                    });
                }
            }
        }
    });
})

And the public server key is achieved. Now we need the private client key, and we need code to dispatch that key to our app every time we run the game. To do so, I’m using memory brute force to find out the private key and BroadcastReceiver of Android to dispatch it from the game to my application.

When the game sends the logic packet, it append the client public key to the payload. What I’m doing is taking this public key, search for it in all the allocated memory ranges and for each found I take 32 bytes before and 32 bytes after and send everything to my application to test the decryption.

if (msgid === 10101) {
    var publicKey = ba2hex(this.context.x1.add(7).readByteArray(32));
    var r = Process.enumerateRanges('rw-');
    console.log('[*] bruting public key -> ' + publicKey);
    var keys = [];

    for (var k in r) {
        if (typeof r[k].file === 'undefined') {
            try {
                Memory.protect(r[k].base, r[k].size, 'rwx');
                var res = Memory.scanSync(r[k].base, r[k].size, publicKey);

                if (res.length > 0) {
                    for (var t in res) {
                        // let's brute in chunk of 32 bytes
                        for (var i = -32; i < 32; i += 32) {
                            if (i < 0) {
                                keys.push(res[t].address.sub(i).readByteArray(32));
                            } else if (i === 0) {// it's publicKey, ignore
                            } else {
                                keys.push(res[t].address.add(i).readByteArray(32));
                            }
                        }
                    }
                }
            } catch (err) {
                console.log(err + '');
            }
        }
    }

    var malloc = new NativeFunction(Module.findExportByName('libc.so', 'malloc'), 'pointer', ['int']);
    var free = new NativeFunction(Module.findExportByName('libc.so', 'free'), 'int', ['pointer']);
    var buf = ptr(malloc(keys.length * 32).toString());
    Memory.protect(buf, keys.length * 32, 'rw-');

    for (var _i = 0; _i < keys.length; _i++) {
        buf.add(_i * 32).writeByteArray(keys[_i]);
    }

    var what = ba2hex(buf.readByteArray(keys.length * 32));
    free(buf);
    console.log('send keys to back (len: ' + what.length + ')\n' + what);
    Java.perform(function () {
        var Intent = Java.use('android.content.Intent');
        var ActivityThread = Java.use('android.app.ActivityThread');
        var Context = Java.use('android.content.Context');
        var ctx = Java.cast(ActivityThread.currentApplication().getApplicationContext(), Context);
        var intent = Intent.$new("com.igio90.keys");
        intent.putExtra('keys', what);
        ctx.sendBroadcast(intent);
        Intent.$dispose();
        ActivityThread.$dispose();
        Context.$dispose();
    });
}

and that’s the code receiving the broadcast

class KeysBroadcast extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        byte[] keys = SCByteBuffer.hexStringToByteArray(
                intent.getStringExtra("keys"). replace(" ", ""));
        int i = 0;

        while (i < keys.length) {
            while (mServer.mClientPublicKey == null) {
                log("client public key is null... waiting");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            byte[] key = new byte[32];
            System.arraycopy(keys, i, key, 0, 32);
            log("testing key: " + SCByteBuffer.bytesToHex(key));
            byte[] testKey = new byte[32];
            TweetNaCl.crypto_scalarmult_base(testKey, key);

            if (SCByteBuffer.bytesToHex(testKey).equals(
                    SCByteBuffer.bytesToHex(mServer.mClientPublicKey))) {
                log("private key bruted: " + SCByteBuffer.bytesToHex(key));
                mServer.mClientPrivateKey = key;
                break;
            }
            i += 32;
        }
    }
}

Once private key is in place, we are now able to decrypt and encrypt back messages. Thanks for reading 🙂 I hope to write again soon about the 9 part of the journey!

One Comment

FourTOne5 March 31, 2020 Reply

Supercell game assets implement different compression algorithms and signatures:

Code Signature Comment
0 NONE Regular non-compressed file
1 LZMA Starts with 5d 00 00
2 SC Starts with SC
3 SCLZ Starts with SC and contains SCLZ
4 SIG Starts with .Sig

Now when I compressed .csv file and put it on /update/logic/ folder, game crashed. Same happened when I put compressed .csv file onto .apk file.

Solution: Latest SC game clients check for .Sig file signature.My module supports compressing multiple old format, but doesn’t support .Sig compression yet (https://github.com/jeanbmar/sc-compression/issues/1 13). I need to spend some time on reversing it to enable the format. Which I Am unable to do Please Help me. My Discord: FourTOne5#6178 . Hope you will contact me.

Leave a Reply to FourTOne5 Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.