Introduction
Strings is a CTF-type Android lab from MobileHackingLab where you use various techniques such as reverse engineering, runtime instrumentation using frida, Android intents with data, and runtime memory analysis to retrieve the flag from the application.
Methodology
- Explore the application
- Entrypoints
- Screens
- Review the validation
- SharedPreferences date check
- Intent action, scheme, and host check
- Secret decryption check
- Bypass the validation
- SharedPreferences date check
- Intent action, scheme, and host check
- Secret decryption check
- Final bypass script
- Retrieve the flag
- ?!?!
- Memory analysis
- Retrieve the real flag
Explore the application
Entrypoints
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="1.0"
android:compileSdkVersion="34"
android:compileSdkVersionCodename="14"
package="com.mobilehackinglab.challenge"
platformBuildVersionCode="34"
platformBuildVersionName="14">
<!-- ... -->
<activity
android:name="com.mobilehackinglab.challenge.Activity2"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="mhl"
android:host="labs"/>
</intent-filter>
</activity>
<activity
android:name="com.mobilehackinglab.challenge.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- ... -->
</manifest>
The application consists of two screens.
The MainActivity screen, which is opened by tapping the app icon on the launcher, contains some text that (I presume) is from a native C++ library.
The Activity2 screen can only be launched by a browsable Intent that has a very specific schema, host and path.
Screens
MainActivity
Activity2
The Activity2 screen is a bit more tricky to display at this point in time.
There are various validation steps that need to be passed before it will display the information we need.
For now, when we open it, and it fails one of the validation checks, it will close the screen immediately.
Review the validation
When looking at the Activity2 source code you will notice all the different validation checks.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_2);
SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
String u_1 = sharedPreferences.getString("UUU0133", null);
boolean isActionView = Intrinsics.areEqual(getIntent().getAction(), "android.intent.action.VIEW");
boolean isU1Matching = Intrinsics.areEqual(u_1, cd());
if (isActionView && isU1Matching) {
Uri uri = getIntent().getData();
if (uri != null && Intrinsics.areEqual(uri.getScheme(), "mhl") && Intrinsics.areEqual(uri.getHost(), "labs")) {
String base64Value = uri.getLastPathSegment();
byte[] decodedValue = Base64.decode(base64Value, 0);
if (decodedValue != null) {
String ds = new String(decodedValue, Charsets.UTF_8);
byte[] bytes = "your_secret_key_1234567890123456".getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
String str = decrypt("AES/CBC/PKCS5Padding", "bqGrDKdQ8zo26HflRsGvVA==", new SecretKeySpec(bytes, "AES"));
if (str.equals(ds)) {
System.loadLibrary("flag");
String s = getflag();
Toast.makeText(getApplicationContext(), s, 1).show();
return;
} else {
finishAffinity();
finish();
System.exit(0);
return;
}
}
finishAffinity();
finish();
System.exit(0);
return;
}
finishAffinity();
finish();
System.exit(0);
return;
}
finishAffinity();
finish();
System.exit(0);
}
SharedPreferences date check
A Date comparison happens and checks that the value, for key UUU0133
, stored in the SharedPreferences is equal to the value returned from the cd()
method.
SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
String u_1 = sharedPreferences.getString("UUU0133", null);
/* ... */
boolean isU1Matching = Intrinsics.areEqual(u_1, cd());
/* ... */
private final String cd() {
String str;
SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy", Locale.getDefault());
String format = sdf.format(new Date());
Intrinsics.checkNotNullExpressionValue(format, "format(...)");
Activity2Kt.cu_d = format;
str = Activity2Kt.cu_d;
if (str != null) {
return str;
}
Intrinsics.throwUninitializedPropertyAccessException("cu_d");
return null;
}
Intent action, scheme, and host check
A String comparison happens and checks that the current Intent action value is android.intent.action.VIEW
.
boolean isActionView = Intrinsics.areEqual(getIntent().getAction(), "android.intent.action.VIEW");
A String comparison happens and checks that the scheme value of the URI is mhl
.
Uri uri = getIntent().getData();
if (uri != null && Intrinsics.areEqual(uri.getScheme(), "mhl") /* ... */)
A String comparison happens and checks that the host value of the URI is labs
.
Uri uri = getIntent().getData();
if (uri != null && /* ... */ Intrinsics.areEqual(uri.getHost(), "labs"))
Secret decryption check
A String is retrieved from the last segment of the URI and then Base64 decoded.
String base64Value = uri.getLastPathSegment();
byte[] decodedValue = Base64.decode(base64Value, 0);
/* ... */
String ds = new String(decodedValue, Charsets.UTF_8);
A String comparison happens and checks that the decoded value is equal to the value returned by the decrypt()
method.
String ds = new String(decodedValue, Charsets.UTF_8);
byte[] bytes = "your_secret_key_1234567890123456".getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
String str = decrypt("AES/CBC/PKCS5Padding", "bqGrDKdQ8zo26HflRsGvVA==", new SecretKeySpec(bytes, "AES"));
if (str.equals(ds))
/* ... */
Bypass the validation
First, I will show a “quick and dirty” way to bypass all the validation.
Please read the bottom sections on how to legitimately pass the validation as well, since it is important to understand how everything works.
Intent action, scheme, and host check
For the URI checks, we are not going to bypass anything since it is easy to give valid data here.
We need to make sure we use a scheme mhl
, host labs
, and then a value for the encoded part.
Since we are bypassing the decrypt method return value later, we can provide any value for the encoded part:
echo -n "bypassed" | base64
# YnlwYXNzZWQ=
The -n
switch is important because it removes the default new line character that echo
puts at the end of the text, it echoes out.
So our final URI is mhl://labs/YnlwYXNzZWQ=
SharedPreferences date check
We use frida to return the same String value for the SharedPreferences getString()
method and the cd()
method.
var sharedPreferences = Java.use("android.app.SharedPreferencesImpl");
sharedPreferences.getString.overload(
"java.lang.String",
"java.lang.String"
).implementation = function (var0, var1) {
console.log("Overriding shared preferences");
return "bypassed";
};
const flagActivity = Java.use("com.mobilehackinglab.challenge.Activity2");
flagActivity.cd.implementation = function () {
console.log("bypassing cd()");
return "bypassed";
};
Secret decryption check
We use frida to return the value bypassed
, which is the Base64 decoded value of our URI String YnlwYXNzZWQK
flagActivity.decrypt.implementation = function (algorithm, cipherText, key) {
console.log("bypassing decrypt()");
return "bypassed";
};
Final bypass script
This script will assign a hard-coded value of bypassed
for all the return values of the methods used during the validation.
Once the validation passes, the native library will be loaded with the loadLibrary()
call and the flag will be retrieved and displayed in a Toast message.
Java.perform(function () {
// Override SharedPreferences getString() return value
var sharedPreferences = Java.use("android.app.SharedPreferencesImpl");
sharedPreferences.getString.overload(
"java.lang.String",
"java.lang.String"
).implementation = function (var0, var1) {
console.log("Overriding shared preferences");
return "bypassed";
};
const flagActivity = Java.use("com.mobilehackinglab.challenge.Activity2");
// Override cd() return value
flagActivity.cd.implementation = function () {
console.log("bypassing cd()");
return "bypassed";
};
// Override decrypt() return value
flagActivity.decrypt.implementation = function (algorithm, cipherText, key) {
console.log("bypassing decrypt()");
return "bypassed";
};
// Override getflag() to print out flag value
flagActivity.getflag.implementation = function () {
console.log("getflag() called");
const returnValue = this.getflag();
console.log("flag value: " + returnValue);
return returnValue;
};
// Get instance of MainActivity and launch Activity2
setTimeout(function () {
Java.choose("com.mobilehackinglab.challenge.MainActivity", {
onMatch: function (mainActivity) {
console.log("Found MainActivity: " + mainActivity);
setTimeout(function () {
startFlagActivity(mainActivity);
}, 200);
},
onComplete: function () {},
});
}, 100);
});
function startFlagActivity(activity) {
const Intent = Java.use("android.content.Intent");
const flagIntent = Intent.$new();
const clazz = "com.mobilehackinglab.challenge.Activity2";
const url = "mhl://labs/YnlwYXNzZWQ=";
flagIntent.setClassName(
clazz.substring(0, clazz.lastIndexOf(".")),
clazz.substring(clazz)
);
flagIntent.setAction("android.intent.action.VIEW");
flagIntent.addCategory("android.intent.category.BROWSABLE");
const Uri = Java.use("android.net.Uri");
const urlAsUri = Uri.parse(url);
flagIntent.setData(urlAsUri);
activity.startActivity(flagIntent);
}
Retrieve the flag
We get the following results when running the script with frida:
frida -U -f "com.mobilehackinglab.challenge" -l strings-with-bypass.js
# Spawned `com.mobilehackinglab.challenge`. Resuming main thread!
# [sdk gphone64 x86 64::com.mobilehackinglab.challenge ]-> Found MainActivity: com.mobilehackinglab.challenge.MainActivity@d4ee3d2
# Overriding shared preferences
# bypassing cd()
# bypassing decrypt()
# getflag() called
# flag value: Success
On the screen, we observe a Toast message displaying the flag value returned from the native library:
?!?!
The value returned is Success
.
The lab mentions that the flag will have the format MHL{...}
so this clearly can’t be the flag value.
Where is the flag?! It was supposed to be returned by the native method call?
After double-checking the lab requirements again, I noticed that they mentioned memory scanning or tracing: which makes me believe the flag might be hidden somewhere in the application memory.
Memory analysis
There are various ways to analyze the application memory.
You can use the Memory module in frida
, you can make a clone of the device memory using third-party forensic tools, or you can use something like objection
.
For this example, I am going to use objection
since I am the most familiar with it.
Retrieve the real flag
To scan the memory we will need to perform the following operations.
Run frida with our bypass script
frida -U -f "com.mobilehackinglab.challenge" -l strings-with-bypass.js
Get the PID for the hooked application
frida-ps -Uai
#PID Name Identifier
#---- ------------- ---------------------------------------
#...
8284 Strings com.mobilehackinglab.challenge
#...
Run objection and attach it to that PID
objection -g 8284 explore
# Using USB device `sdk gphone64 x86 64`
# Agent injected and responds ok!
# _ _ _ _
# ___| |_|_|___ ___| |_|_|___ ___
# | . | . | | -_| _| _| | . | |
# |___|___| |___|___|_| |_|___|_|_|
# |___|(object)inject(ion) v1.11.0
# Runtime Mobile Exploration
# by: @leonjza from @sensepost
# [tab] for command suggestions
Scan the memory using the memory module search feature
com.mobilehackinglab.challenge on (google: 14) [usb] memory search MHL{ --string
# Searching for: 4d 48 4c 7b
# 74c7a9660fc0 4d 48 4c 7b 49 4e 5f 54 48 45 5f 4d 45 4d 4f 52 MHL{IN_THE_MEMOR
# 74c7a9660fd0 59 7d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 Y}..............
# 74c7a9660fe0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
# Pattern matched at 1 addresses
The flag is MHL{IN_THE_MEMORY}
Extra - Pass validation checks
Now, let’s see how to legitimately pass all the validation checks and retrieve the flag.
We will be performing the validation steps in reverse since we will need the decrypted text value in order to create a successful Intent.
Secret decryption check
To determine the decrypted text, you can use either of the following methods:
- Create a frida hook to hook the method and print out the decrypted text value
- Use a tool like CyberChef to perform the decryption, since you have all the input parameters
- Create a new Android Studio project and paste the source code in there and run it
frida
Modify the decrypt method to look log the decrypted value
flagActivity.decrypt.implementation = function (algorithm, cipherText, key) {
console.log("bypassing decrypt()");
const decryptedValue = this.decrypt(algorithm, cipherText, key);
console.log("decrypted value: " + decryptedValue);
return "bypassed";
};
Run frida with the modified script:
frida -U -f "com.mobilehackinglab.challenge" -l strings-with-bypass.js
# ...
Spawned `com.mobilehackinglab.challenge`. Resuming main thread!
[sdk gphone64 x86 64::com.mobilehackinglab.challenge ]-> Found MainActivity: com.mobilehackinglab.challenge.MainActivity@d4ee3d2
Overriding shared preferences
bypassing cd()
bypassing decrypt()
decrypted value: mhl_secret_1337
getflag() called
flag value: Success
# ...
The decrypted value is mhl_secret_1337
CyberChef
CyberChef is one of my favourite tools to transform or manipulate data.
You can clone the repo and run it locally or use the hosted version on github pages.
Let’s have another look at the decryption code.
public final String decrypt(String algorithm, String cipherText, SecretKeySpec key) {
Intrinsics.checkNotNullParameter(algorithm, "algorithm");
Intrinsics.checkNotNullParameter(cipherText, "cipherText");
Intrinsics.checkNotNullParameter(key, "key");
Cipher cipher = Cipher.getInstance(algorithm);
try {
byte[] bytes = Activity2Kt.fixedIV.getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
IvParameterSpec ivSpec = new IvParameterSpec(bytes);
cipher.init(2, key, ivSpec);
byte[] decodedCipherText = Base64.decode(cipherText, 0);
byte[] decrypted = cipher.doFinal(decodedCipherText);
Intrinsics.checkNotNull(decrypted);
return new String(decrypted, Charsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Decryption failed", e);
}
}
We will need the following information:
- Algorithm
- Cipher text
- Secret Key
- IV (Initialization vector)
Lucky for us all of these can be found in the source code:
// Secret Key
byte[] bytes = "your_secret_key_1234567890123456".getBytes(Charsets.UTF_8);
// Algorithm, cipher text
String str = decrypt("AES/CBC/PKCS5Padding", "bqGrDKdQ8zo26HflRsGvVA==", new SecretKeySpec(bytes, "AES"));
// IV
byte[] bytes = Activity2Kt.fixedIV.getBytes(Charsets.UTF_8);
public final class Activity2Kt {
private static String cu_d = null;
public static final String fixedIV = "1234567890123456";
}
Let’s put these values into CyberChef and see if we get the expected value mhl_secret_1337
You can also use this URL to see the results:
https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true,false)AES_Decrypt(%7B'option':'UTF8','string':'your_secret_key_1234567890123456'%7D,%7B'option':'UTF8','string':'1234567890123456'%7D,'CBC','Raw','Raw',%7B'option':'Hex','string':''%7D,%7B'option':'Hex','string':''%7D)&input=YnFHckRLZFE4em8yNkhmbFJzR3ZWQT09
Awesome! CyberChef also gives us the value mhl_secret_1337
Android Studio
The following code snippets are in Kotlin. You can use Java, but for my setup I decided to convert it to Kotlin since that is what I use mostly for mobile development.
fun decryptAndLogValue(){
val bytes = "your_secret_key_1234567890123456".toByteArray(UTF_8)
Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)")
val str = decrypt("AES/CBC/PKCS5Padding",
"bqGrDKdQ8zo26HflRsGvVA==", SecretKeySpec(bytes, "AES"))
Log.d(MainActivity::class.java.simpleName, "decrypted: $str")
}
fun decrypt(algorithm: String?, cipherText: String?, key: SecretKeySpec?): String {
Intrinsics.checkNotNullParameter(algorithm, "algorithm")
Intrinsics.checkNotNullParameter(cipherText, "cipherText")
Intrinsics.checkNotNullParameter(key, "key")
val cipher = Cipher.getInstance(algorithm)
try {
val bytes: ByteArray = "1234567890123456".toByteArray(UTF_8)
Intrinsics.checkNotNullExpressionValue(
bytes,
"this as java.lang.String).getBytes(charset)"
)
val ivSpec = IvParameterSpec(bytes)
cipher.init(2, key, ivSpec)
val decodedCipherText = Base64.decode(cipherText, 0)
val decrypted = cipher.doFinal(decodedCipherText)
Intrinsics.checkNotNull(decrypted)
return String(decrypted, UTF_8)
} catch (e: Exception) {
throw RuntimeException("Decryption failed", e)
}
}
After running it on my emulator, I observed the results in the log at the bottom.
2024-10-31 21:32:40.553 10756-10756 MainActivity pid-10756 D decrypted: mhl_secret_1337
SharedPreferences date check
We already know that there are some SharedPreferences String comparisons happening, but where is that information supposed to come from?
After browsing through the source code for a bit, I discovered this method in the MainActivity class.
public final void KLOW() {
SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
SharedPreferences.Editor editor = sharedPreferences.edit();
Intrinsics.checkNotNullExpressionValue(editor, "edit(...)");
SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy", Locale.getDefault());
String cu_d = sdf.format(new Date());
editor.putString("UUU0133", cu_d);
editor.apply();
}
Interesting enough, this code snippets stores the exact data that is required by the validation in the Activity2 class.
SharedPreferences sharedPreferences = getSharedPreferences("DAD4", 0);
String u_1 = sharedPreferences.getString("UUU0133", null);
Since this is also a public method in the class, I am sure that we will be able to call this from a frida script once we have an instance of MainActivity.
Intent action, scheme, and host check
To create the final part of our Uri we need to Base64 encode the decrypted String we retrieved earlier and add it as a path to the Uri.
echo -n "mhl_secret_1337" | base64
# bWhsX3NlY3JldF8xMzM3
The final valid URI for the Intent is
mhl://labs/bWhsX3NlY3JldF8xMzM3
Retrieve the real flag
We can modify our previous script, remove the bypass logic, and add a call to the MainActivity KLOW()
method to set up the required SharedPreferences.
What would make this even better? If we could scan for the flag value in our current frida script.
This is the first time I attempt to use the memory scanning in frida, lucky for me, I found this awesome article explaining it well.
This is the finale script that we can use to run everything and scan the memory at the end for the flag.
Java.perform(function () {
setTimeout(function () {
Java.choose("com.mobilehackinglab.challenge.MainActivity", {
onMatch: function (mainActivity) {
// Setup the SharedPreferences for our validation checks
mainActivity.KLOW();
setTimeout(function () {
startFlagActivity(mainActivity);
setTimeout(function () {
readFlagFromMemory();
}, 200);
}, 200);
},
onComplete: function () {},
});
}, 100);
});
function startFlagActivity(activity) {
const Intent = Java.use("android.content.Intent");
const flagIntent = Intent.$new();
const clazz = "com.mobilehackinglab.challenge.Activity2";
const url = "mhl://labs/bWhsX3NlY3JldF8xMzM3";
flagIntent.setClassName(
clazz.substring(0, clazz.lastIndexOf(".")),
clazz.substring(clazz)
);
flagIntent.setAction("android.intent.action.VIEW");
flagIntent.addCategory("android.intent.category.BROWSABLE");
const Uri = Java.use("android.net.Uri");
const urlAsUri = Uri.parse(url);
flagIntent.setData(urlAsUri);
activity.startActivity(flagIntent);
}
function readFlagFromMemory() {
// We want to scan the memory allocated to the libflag.so native library
var module = Process.findModuleByName("libflag.so");
// We want to search for a pattern that starts with "MHL{"
var pattern = "4d 48 4c 7b"; // "MHL{" in HEX
Memory.scan(module.base, module.size, pattern, {
onMatch(address, size) {
// We found a match, now lets loop through the memory character-by-character
// until we find the '}' character, which is the end of the flag
var flag = "";
var offset = 0;
while (!flag.endsWith("}")) {
flag = Memory.readUtf8String(address, size + offset);
offset++;
}
console.log("Found the flag in memory: " + flag);
},
onError(reason) {
console.error(
`Something went wrong while scanning for the flag in memory: ${reason}`
);
},
onComplete() {
console.log("Finished scanning for the flag in memory.");
},
});
}
And when we run this it gives the following results.
frida -U -f "com.mobilehackinglab.challenge" -l strings.js
Spawned `com.mobilehackinglab.challenge`. Resuming main thread!
[sdk gphone64 x86 64::com.mobilehackinglab.challenge ]-> Found the flag in memory: MHL{IN_THE_MEMORY}
Finished scanning for the flag in memory.
Final thoughts
This challenge was a lot of fun but also quite frustrating at the same time.
It took a while for me to set up my scripts in such a way that I could bypass the application exiting code, which would cause the process to terminate each time a validation failed.
I find that the async nature of JavaScript makes it challenging to write scripts that I would like to run sequentially or blocking. Perhaps it would be a good idea to investigate using Python with frida in the future.
It was nice to learn some new ways of analysing memory, and it has certainly piqued my curiosity to play around with some lower level code.