[Writeup] Thailand Cyber Top Talent 2025: Bangkok Casino (Mobile 300 Pts) - Android Application Manual Static Analysis Solution
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
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:
- 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);
- 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);
- 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();
- 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);
- XOR Unmask: The decrypted data is passed to an
xorUnmask()
function, suggesting another layer of obfuscation.
byte[] unmasked = xorUnmask(decrypted);
- 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
.
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
- A key seed string is constructed by concatenating two values: the result of
Telemetry.INSTANCE.getChannelNames()
andBuildConfig.BASELINE
.
String keySeed = ArraysKt.joinToString$default(Telemetry.INSTANCE.getChannelNames(), "", (CharSequence) null, (CharSequence) null, 0, (CharSequence) null, (Function1) null, 62, (Object) null) + BuildConfig.BASELINE;
- 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);
- 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
- An IV seed string is constructed by concatenating
Telemetry.INSTANCE.getRegions()
andNotificationCompat.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;
- 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);
- 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
andregions
are found incom.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
, andCATEGORY_PROMO
are standard string constants fromandroidx.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 thecom.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.
Next, let's examine Java_com_skygrove_games_hilow_core_crypto_NativeCrypto_xorUnmask
.
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:
flag{d8c74e1ab1641b40884363fb456bf4ef}