Reconix LogoReconix
Featured image for [Writeup] Thailand Cyber Top Talent 2025: Bangkok Casino (Mobile 300 Pts) - Android Application Manual Static Analysis Solution

[Writeup] Thailand Cyber Top Talent 2025: Bangkok Casino (Mobile 300 Pts) - Android Application Manual Static Analysis Solution

Reconix Team (Sorawish Laovakul)

This writeup details the solution for the "Bangkok Casino" mobile challenge from Thailand Cyber Top Talent 2025. The task is to reverse engineer an Android application to uncover a hidden flag. The challenge's core lies in dissecting a multi-layered encryption scheme protecting a promotional code.

Challenge APK File: thctt2025_open_mobile3_bangkok-casino.apk

Challenge overview showing the Bangkok Casino app

While this writeup focuses on a complete manual static analysis, it's worth noting a much faster solution exists. Using a dynamic instrumentation framework like Frida, one could simply hook the decodePromo() function and intercept its return value. This would yield the flag directly without needing to reverse-engineer the entire encryption process. However, for the sake of learning, we'll dive deep into the manual approach to explore the cryptographic and native code mechanisms at play.


Initial Reconnaissance: Decompiling the App

After decompiling the APK with JADX, a quick search for interesting strings like "flag", "promo", or "encrypt" leads us to the com.skygrove.games.hilow.core.crypto.SecretCodec class. Inside, a function named decodePromo() immediately stands out. This function appears to handle the logic for decoding a promotional code, making it our primary target for investigation.

    public final String decodePromo(Context context) throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, IOException, InvalidKeyException, InvalidAlgorithmParameterException {
        Intrinsics.checkNotNullParameter(context, "context");
        InputStream inputStreamOpen = context.getAssets().open("metrics.bin");
        Intrinsics.checkNotNullExpressionValue(inputStreamOpen, "open(...)");
        Reader inputStreamReader = new InputStreamReader(inputStreamOpen, Charsets.UTF_8);
        BufferedReader bufferedReader = inputStreamReader instanceof BufferedReader ? (BufferedReader) inputStreamReader : new BufferedReader(inputStreamReader, 8192);
        try {
            String b64String = ReadWrite2.readText(bufferedReader);
            Closeable3.closeFinally(bufferedReader, null);
            byte[] encrypted = Base64.decode(StringsKt.trim((CharSequence) b64String).toString(), 0);
            Tuples<byte[], byte[]> tuplesDeriveKeyAndIv$app_debug = deriveKeyAndIv$app_debug();
            byte[] key = tuplesDeriveKeyAndIv$app_debug.component1();
            byte[] iv = tuplesDeriveKeyAndIv$app_debug.component2();
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            SecretKeySpec secretKey = new SecretKeySpec(key, "AES");
            IvParameterSpec ivSpec = new IvParameterSpec(iv);
            cipher.init(2, secretKey, ivSpec);
            byte[] decrypted = cipher.doFinal(encrypted);
            Intrinsics.checkNotNull(decrypted);
            byte[] unmasked = xorUnmask(decrypted);
            GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(unmasked));
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            while (true) {
                int i = gzipIn.read(buffer);
                byte[] encrypted2 = encrypted;
                if (i != -i) {
                    out.write(buffer, 0, i);
                    encrypted = encrypted2;
                } else {
                    byte[] byteArray = out.toByteArray();
                    Intrinsics.checkNotNullExpressionValue(byteArray, "toByteArray(...)");
                    return new String(byteArray, Charsets.UTF_8);
                }
            }
        } finally {
        }
    }

The decodePromo() function orchestrates the entire decryption process. Let's break it down step-by-step.

The Decryption Pipeline

Here's a summary of what the decodePromo() function does:

  1. Load Data: It reads the content of assets/metrics.bin.
    Intrinsics.checkNotNullParameter(context, "context");
    InputStream inputStreamOpen = context.getAssets().open("metrics.bin");
    Intrinsics.checkNotNullExpressionValue(inputStreamOpen, "open(...)");
    Reader inputStreamReader = new InputStreamReader(inputStreamOpen, Charsets.UTF_8);
    BufferedReader bufferedReader = inputStreamReader instanceof BufferedReader ? (BufferedReader) inputStreamReader : new BufferedReader(inputStreamReader, 8192);
  1. Base64 Decode: The content is treated as a Base64 string and decoded into a byte array.
    try {
        String b64String = ReadWrite2.readText(bufferedReader);
        Closeable3.closeFinally(bufferedReader, null);
        byte[] encrypted = Base64.decode(StringsKt.trim((CharSequence) b64String).toString(), 0);
  1. Derive Key/IV: It calls deriveKeyAndIv$app_debug() to generate a cryptographic key and initialization vector (IV).
        Tuples<byte[], byte[]> tuplesDeriveKeyAndIv$app_debug = deriveKeyAndIv$app_debug();
        byte[] key = tuplesDeriveKeyAndIv$app_debug.component1();
        byte[] iv = tuplesDeriveKeyAndIv$app_debug.component2();
  1. AES Decrypt: The byte array is decrypted using AES in CBC mode with PKCS5 padding.
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        SecretKeySpec secretKey = new SecretKeySpec(key, "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(2, secretKey, ivSpec);
        byte[] decrypted = cipher.doFinal(encrypted);
        Intrinsics.checkNotNull(decrypted);
  1. XOR Unmask: The decrypted data is passed to an xorUnmask() function, suggesting another layer of obfuscation.
        byte[] unmasked = xorUnmask(decrypted);
  1. Gzip Decompress: The final byte array is decompressed using Gzip, revealing the plaintext string.
        GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(unmasked));
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        while (true) {
            int i = gzipIn.read(buffer);
            byte[] encrypted2 = encrypted;
            if (i != -1) {
                out.write(buffer, 0, i);
                encrypted = encrypted2;
            } else {
                byte[] byteArray = out.toByteArray();
                Intrinsics.checkNotNullExpressionValue(byteArray, "toByteArray(...)");
                return new String(byteArray, Charsets.UTF_8);
            }
        }
    } finally {
    }
}

Our mission is to replicate this entire pipeline. First, let's look at the encrypted data in assets/metrics.bin.

Contents of metrics.bin showing encrypted Base64 data
2Cmmm5b9s0CNz1bB2JcN0mRwsKuvLNjGUD/sd7HZdgljeJR6h2DHub0dv64pT5SR62uyLWD6x/RWeBNbkNYbCA==

Layer 1: AES Key & IV Derivation

To perform the AES decryption, we need the correct key and IV. These are generated by the deriveKeyAndIv$app_debug() function within the same SecretCodec class.

     public final Tuples<byte[], byte[]> deriveKeyAndIv$app_debug() throws NoSuchAlgorithmException {
        String keySeed = ArraysKt.joinToString$default(Telemetry.INSTANCE.getChannelNames(), "", (CharSequence) null, (CharSequence) null, 0, (CharSequence) null, (Function1) null, 62, (Object) null) + BuildConfig.BASELINE;
        String ivSeed = ArraysKt.joinToString$default(Telemetry.INSTANCE.getRegions(), "", (CharSequence) null, (CharSequence) null, 0, (CharSequence) null, (Function1) null, 62, (Object) null) + NotificationCompat.CATEGORY_PROMO;
        MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
        byte[] bytes = keySeed.getBytes(Charsets.UTF_8);
        Intrinsics.checkNotNullExpressionValue(bytes, "getBytes(...)");
        byte[] sha = messageDigest.digest(bytes);
        MessageDigest messageDigest2 = MessageDigest.getInstance("MD5");
        byte[] bytes2 = ivSeed.getBytes(Charsets.UTF_8);
        Intrinsics.checkNotNullExpressionValue(bytes2, "getBytes(...)");
        byte[] md5 = messageDigest2.digest(bytes2);
        Intrinsics.checkNotNull(sha);
        byte[] key = ArraysKt.copyOfRange(sha, 0, 16);
        Intrinsics.checkNotNull(md5);
        byte[] iv = ArraysKt.copyOfRange(md5, 0, 16);
        return Tuples3.m135to(key, iv);
    }

The logic is straightforward:

AES Key Generation

  1. A key seed string is constructed by concatenating two values: the result of Telemetry.INSTANCE.getChannelNames() and BuildConfig.BASELINE.
       String keySeed = ArraysKt.joinToString$default(Telemetry.INSTANCE.getChannelNames(), "", (CharSequence) null, (CharSequence) null, 0, (CharSequence) null, (Function1) null, 62, (Object) null) + BuildConfig.BASELINE;
  1. The SHA-256 hash of this seed string is calculated (32 bytes).
        byte[] bytes = keySeed.getBytes(Charsets.UTF_8);
        Intrinsics.checkNotNullExpressionValue(bytes, "getBytes(...)");
        byte[] sha = messageDigest.digest(bytes);
  1. The first 16 bytes of the 32-byte SHA-256 hash are used as the AES key.
        Intrinsics.checkNotNull(sha);
        byte[] key = ArraysKt.copyOfRange(sha, 0, 16);

AES IV Generation

  1. An IV seed string is constructed by concatenating Telemetry.INSTANCE.getRegions() and NotificationCompat.CATEGORY_PROMO.
        String ivSeed = ArraysKt.joinToString$default(Telemetry.INSTANCE.getRegions(), "", (CharSequence) null, (CharSequence) null, 0, (CharSequence) null, (Function1) null, 62, (Object) null) + NotificationCompat.CATEGORY_PROMO;
  1. The MD5 hash of this seed string is calculated (16 bytes).
        byte[] bytes2 = ivSeed.getBytes(Charsets.UTF_8);
        Intrinsics.checkNotNullExpressionValue(bytes2, "getBytes(...)");
        byte[] md5 = messageDigest2.digest(bytes2);
  1. The resulting 16-byte MD5 hash is used directly as the AES IV.
        Intrinsics.checkNotNull(md5);
        byte[] iv = ArraysKt.copyOfRange(md5, 0, 16);

Now, we need to hunt down these four constant values.

  • channelNames and regions are found in com.skygrove.games.hilow.core.analytics.Telemetry:
    private static final String[] channelNames = {"organic", "paid", NotificationCompat.CATEGORY_EMAIL, NotificationCompat.CATEGORY_SOCIAL};
    private static final String[] regions = {"US", "EU", "APAC", "LATAM"};
    
  • CATEGORY_EMAIL, CATEGORY_SOCIAL, and CATEGORY_PROMO are standard string constants from androidx.core.app.NotificationCompat:
    public class NotificationCompat {
        public static final int BADGE_ICON_LARGE = 2;
        public static final int BADGE_ICON_NONE = 0;
        public static final int BADGE_ICON_SMALL = 1;
        public static final String CATEGORY_ALARM = "alarm";
        public static final String CATEGORY_CALL = "call";
        public static final String CATEGORY_EMAIL = "email";
        public static final String CATEGORY_ERROR = "err";
        public static final String CATEGORY_EVENT = "event";
        public static final String CATEGORY_LOCATION_SHARING = "location_sharing";
        public static final String CATEGORY_MESSAGE = "msg";
        public static final String CATEGORY_MISSED_CALL = "missed_call";
        public static final String CATEGORY_NAVIGATION = "navigation";
        public static final String CATEGORY_PROGRESS = "progress";
        public static final String CATEGORY_PROMO = "promo";
        public static final String CATEGORY_RECOMMENDATION = "recommendation";
        public static final String CATEGORY_REMINDER = "reminder";
        public static final String CATEGORY_SERVICE = "service";
        public static final String CATEGORY_SOCIAL = "social";
        public static final String CATEGORY_STATUS = "status";
    
  • BASELINE is found in the com.skygrove.games.hilow.BuildConfig class:
    public final class BuildConfig {
        public static final String APPLICATION_ID = "com.skygrove.games.hilow.debug";
        public static final String BASELINE = "19-07-12";
        public static final String BUILD_TYPE = "debug";
        public static final boolean DEBUG = Boolean.parseBoolean("true");
        public static final int VERSION_CODE = 103;
        public static final String VERSION_NAME = "1.3.0-debug";
    }
    

Putting it all together:

  • keySeed = "organic" + "paid" + "email" + "social" + "19-07-12" = "organicpaidemailsocial19-07-12"
  • ivSeed = "US" + "EU" + "APAC" + "LATAM" + "promo" = "USEUAPACLATAMpromo"

We now have everything needed for the first decryption step!


Layer 2: The Native XOR Mask

After AES decryption, the data is passed to a function called xorUnmask. This function is part of the NativeCrypto class, which uses the Java Native Interface (JNI) to call code from a native library.

public final class NativeCrypto {
    public static final int $stable = 0;
    public static final NativeCrypto INSTANCE = new NativeCrypto();

    public final native int getMaskValue();

    public final native boolean validateMask(int maskValue);

    public final native byte[] xorUnmask(byte[] data, int maskValue);

    private NativeCrypto() {
    }

    static {
        System.loadLibrary("sthcasino");
    }
}

The line System.loadLibrary("sthcasino") tells us to look for libsthcasino.so in the APK's lib directory. We can analyze this native shared object file using a disassembler like Ghidra.

Inside libsthcasino.so, we find the native implementation of getMaskValue. This function simply returns a hardcoded integer constant.

The function Java_com_skygrove_games_hilow_core_crypto_NativeCrypto_getMaskValue returns the constant value 0xa713e24c. This is our XOR mask.

Ghidra analysis showing the XOR mask value 0xa713e24c

Next, let's examine Java_com_skygrove_games_hilow_core_crypto_NativeCrypto_xorUnmask.

Ghidra disassembly of the xorUnmask function logic

The logic here is a simple repeating XOR cipher. It iterates through the input data and XORs each byte with one of the four bytes from the 0xA713E24C mask.

  • The 1st byte is XORed with 0xA7.
  • The 2nd byte is XORed with 0x13.
  • The 3rd byte is XORed with 0xE2.
  • The 4th byte is XORed with 0x4C.
  • The 5th byte is XORed with 0xA7, and so on.

Now we have the final piece of the puzzle.


Putting It All Together: The Solution

With all the components reverse-engineered, we can write a Python script to automate the decryption process and retrieve the flag.

solve.py

import base64, hashlib, zlib
from Crypto.Cipher import AES

# 1. Load metrics.bin.
b64_data = "2Cmmm5b9s0CNz1bB2JcN0mRwsKuvLNjGUD/sd7HZdgljeJR6h2DHub0dv64pT5SR62uyLWD6x/RWeBNbkNYbCA=="

# 2. Decode it from Base64 into bytes.
data = base64.b64decode(b64_data)

# 3. Get an AES key and IV from deriveKeyAndIv$app_debug().
keySeed = "organicpaidemailsocial19-07-12"
ivSeed  = "USEUAPACLATAMpromo"
key = hashlib.sha256(keySeed.encode()).digest()[:16]
iv  = hashlib.md5(ivSeed.encode()).digest()

# 4. Decrypt the bytes with AES (CBC mode, PKCS5 padding).
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(data)

# 5. Undo the XOR mask on the decrypted data.
mask = 0xA713E24C
out = bytearray(pt)
for i in range(len(out)):
    out[i] ^= (mask >> (24 - 8*(i % 4))) & 0xFF

# 6. GZIP-decompress the result.
flag = zlib.decompress(out, wbits=16+zlib.MAX_WBITS).decode()
print(flag)

Running the script reveals the flag:

Python script execution showing the decrypted flag

flag{d8c74e1ab1641b40884363fb456bf4ef}

Articles

More Blog Posts

Continue exploring our cybersecurity insights and resources

Featured image for [รีวิว] eMAPT 2025 - Mobile Penetration Testing Certification ที่ข้อสอบเปลี่ยนไปหลังจากที่ผมกดซื้อคอร์ส!

[รีวิว] eMAPT 2025 - Mobile Penetration Testing Certification ที่ข้อสอบเปลี่ยนไปหลังจากที่ผมกดซื้อคอร์ส!

August 18, 2025Reconix Team (Kittipat Dechkul)

รีวิว eMAPT 2025 เปลี่ยนแปลงครั้งใหญ่ จากข้อสอบ 7 วัน เป็น 12 ชั่วโมง พร้อมเทคนิค Dynamic Analysis, Frida, และ Mobile App Security ที่ใช้ได้จริงในงานของ Pentester จริง ๆ

Featured image for [รีวิว] eWPT Certification ฉบับภาษาไทย 2025 ราคา ข้อสอบ และวิธีเตรียมตัวแบบละเอียด

[รีวิว] eWPT Certification ฉบับภาษาไทย 2025 ราคา ข้อสอบ และวิธีเตรียมตัวแบบละเอียด

August 13, 2025Reconix Team (Wachirawit Kanpanluk)

รีวิวประสบการณ์สอบ eWPT (INE Security/eLearnSecurity Web Application Penetration Tester) ข้อสอบ 50 ข้อ 10 ชม. ราคา $499-599 พร้อมเทคนิคเตรียมตัวแบบมือใหม่ผ่านใน 1 เดือน