> For the complete documentation index, see [llms.txt](https://sansong.gitbook.io/cyber/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://sansong.gitbook.io/cyber/capture-the-flag-ctf/writeups/2025/neopasswd-midnight-flag-2025.md).

# neopasswd @Midnight Flag 2025

Playing with Frida

## TL;DR

This is an android app that decrypts the flag when a button is pressed. However, some access control and other checks are made to ensure that it's impossible to get the flag in a legit way. After reversing the app, I used frida to hook the native method that creates the key and decrypt the flag directly.

It's an interesting challenge to learn about frida hooking :)

## Description

`reverse` `android` <mark style="color:orange;">`medium`</mark>

{% hint style="success" %}
This is the second out of the 3 challenges of the android category.
{% endhint %}

You can download the challenge from this archive.

{% file src="/files/kudbqEoDJbcXfrzXsyOb" %}

## Solution

We are given an APK archive for this one. The `AndroidManifest.xml` file contains info regarding the target android version for the app in the `uses-sdk` tag. It can be accessed using the [jadx](https://github.com/skylot/jadx) decompiler or by extracting the archive with apktool for example.

{% hint style="success" %}

```sh
apktool d neopasswd2.apk -o output_folder
```

{% endhint %}

```xml
// AndroidManifest.xml
<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.example.neopasswd2" platformBuildVersionCode="34" platformBuildVersionName="14">
    [...]
    <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34"/>
    [...]
</manifest>
```

Now that we know the android version we can use an emulator to run the app. I chose [budtmo](https://github.com/budtmo/docker-android) as my emulator. It's a docker image built to be used for everything related to Android.

```bash
docker run -d -p 6080:6080 -p 5554:5554 -p 5555:5555 -p 4723:4723 -e EMULATOR_DEVICE="Samsung Galaxy S10" -e WEB_VNC=true --device /dev/kvm --name android-container budtmo/docker-android:emulator_14.0_v2.16.2-p0
```

Running the app, we can see that we first need to login or register.

<figure><img src="https://1813806532-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FZRRTPIEA4wb6exZozwS0%2Fuploads%2FxTTTAOTvho2A5mXl2x8Z%2Fneopasswd-img.png?alt=media&amp;token=f484b4db-ddcb-48f3-b4c7-b1dff269cb81" alt="" width="166"><figcaption><p>Register/login screen</p></figcaption></figure>

After creating an account and playing with the app, we notice the bottom right button that displays the following message: `Sorry, only an admin can read messages. :/`. Nothing else seems interesting.

<figure><img src="https://1813806532-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FZRRTPIEA4wb6exZozwS0%2Fuploads%2FjYztTCueO7LNGok4pbCN%2Fneopasswd-img2.png?alt=media&amp;token=9c8efe0f-24d7-4bff-86e0-4b08040e4f20" alt="" width="164"><figcaption><p>Application main screen</p></figcaption></figure>

Time to jump into the source code. The `onCreate` method of the entrypoint `MainActivity` is called on the app startup. It starts by fetching the user in the database `UserContract.CONTENT_URI` with the [`Cursor` interface](https://developer.android.com/reference/android/database/Cursor).

<pre class="language-javascript"><code class="lang-javascript">// com.example.neopasswd2.MainActivity
public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;
    private boolean isAdmin = false;
    private AppBarConfiguration mAppBarConfiguration;

    public native String getObfuscatedString();

    /* JADX INFO: Access modifiers changed from: protected */
    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    public void onCreate(Bundle savedInstanceState) {
        [...]
<strong>        final SharedPreferences prefs = getSharedPreferences("session", 0);
</strong><strong>        String currentUsername = prefs.getString("current_user", null);
</strong>        [...]
<strong>        Cursor cursor = getContentResolver().query(UserContract.CONTENT_URI, null, "username=?", new String[]{currentUsername}, null);
</strong>        [...]
    }
}
</code></pre>

The `UserContract` class show below contains the database uri.

<pre class="language-javascript"><code class="lang-javascript">// com.example.neopasswd2.UserContract
public final class UserContract {
    public static final String AUTHORITY = "com.example.neopasswd2.provider";
<strong>    public static final Uri CONTENT_URI = Uri.parse("content://com.example.neopasswd2.provider/users");
</strong>
    /* loaded from: classes3.dex */
    public static class UserEntry implements BaseColumns {
        public static final String COLUMN_ADMIN = "admin";
        public static final String COLUMN_PASSWORD = "password";
        public static final String COLUMN_USERNAME = "username";
        public static final String TABLE_NAME = "users";
    }
    [...]
}
</code></pre>

After grabbing the user, it checks if it's an admin and sets the variable `isAdmin` accordingly.

```javascript
// com.example.neopasswd2.MainActivity
if (cursor != null && cursor.moveToFirst()) {
    int adminValue = cursor.getInt(cursor.getColumnIndexOrThrow(UserContract.UserEntry.COLUMN_ADMIN));
    this.isAdmin = adminValue == 1;
    cursor.close();
}
```

The admin status is set to 0 for each user when the account is created. It doesn't seem possible to have an admin account.

<pre class="language-javascript"><code class="lang-javascript">// com.example.neopasswd2.LoginActivity
public class LoginActivity extends AppCompatActivity {
    [...]
    public void registerUser(View view) {
        String username = this.usernameField.getText().toString();
        String password = this.passwordField.getText().toString();
        if (username.isEmpty() || password.isEmpty()) {
            Toast.makeText(this, "Veuillez remplir tous les champs.", 0).show();
            return;
        }
        ContentValues values = new ContentValues();
        values.put(UserContract.UserEntry.COLUMN_USERNAME, username);
        values.put(UserContract.UserEntry.COLUMN_PASSWORD, password);
<strong>        values.put(UserContract.UserEntry.COLUMN_ADMIN, (Integer) 0);
</strong>        getContentResolver().insert(UserContract.CONTENT_URI, values);
        SharedPreferences prefs = getSharedPreferences("session", 0);
        prefs.edit().putString("current_user", username).apply();
        startActivity(new Intent(this, MainActivity.class));
        finish();
    }
    [...]
}
</code></pre>

Back to `MainActivity` the `onClick` method is executed when the bottom right button we noticed earlier is pressed. It starts by checking if the user is an admin and prints the message we saw earlier: `Sorry, only an admin can read messages. :/` .

{% hint style="info" %}
As we just saw, this message is printed everytime since we can't have a legit admin account :(
{% endhint %}

Otherwise, it decrypts something using the `tryDecrypt` method and prints the results if the function doesn't return `null`. This seems to be the encrypted flag.

<pre class="language-java"><code class="lang-java">// com.example.neopasswd2.MainActivity
this.binding.appBarMain.fab.setOnClickListener(new View.OnClickListener() {
    private boolean firstClick = true;

    @Override // android.view.View.OnClickListener
    public void onClick(View view) {
        if (!MainActivity.this.isAdmin) {
<strong>            Snackbar.make(view, "Sorry, only an admin can read messages. :/", 0).setAnchorView(R.id.fab).show();
</strong>        } else if (!this.firstClick) {
<strong>            String decrypted = MainActivity.this.tryDecrypt("Mszhl+UnftsTwm7Ule0V28WQMptqd8uoc4AbDSBKavw=");
</strong>            if (decrypted != null) {
                Snackbar.make(view, "★" + decrypted, 0).setAnchorView(R.id.fab).show();
            } else {
                Snackbar.make(view, "Sry bro, you don't have permission to read the notification :(", 0).setAnchorView(R.id.fab).show();
            }
        } else {
            Snackbar.make(view, "A secret and important notification is about to arrive..", 0).setAnchorView(R.id.fab).show();
            this.firstClick = false;
        }
    }
});
</code></pre>

Let's look at the crypto part. It does the following:

* decode the base64 string
* compare its length with the result of `getMaxAllowedLength`
* retrieve the key with the `getObfuscatedString` method
* decrypt using `AES` in `ECB` mode with `PKCS5Padding`
* return the decrypted string (likely the flag)

```javascript
// com.example.neopasswd2.MainActivity
public String tryDecrypt(String base64Data) {
    try {
        byte[] encrypted = Base64.decode(base64Data, 0);
        if (encrypted.length > getMaxAllowedLength()) {
            return null;
        }
        byte[] key = getObfuscatedString().getBytes("UTF-8");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(2, new SecretKeySpec(key, "AES"));
        byte[] decrypted = cipher.doFinal(encrypted);
        String result = new String(decrypted, "UTF-8").trim();
        return result;
    } catch (Exception e) {
        return null;
    }
}
```

The `getMaxAllowedLength` method always returns 3 so since the encrypted data in longer, `tryDecrypt` will always return `null` and therefore `onClick` will display the error message `Sry bro, you don't have permission to read the notification :(`. We'll come back to this later.

```javascript
// com.example.neopasswd2.MainActivity
public int getMaxAllowedLength() {
    return 3;
}
```

As we saw, the key is retrieved using the `getObfuscatedString` method. Its defined at the beginning of the class as a `native` method. We can see that the class loads a native library.

```javascript
// com.example.neopasswd2.MainActivity
public class MainActivity extends AppCompatActivity {
    [...]
    public native String getObfuscatedString();
    [...]
    static {
        System.loadLibrary("native-lib");
    }
```

We need to know the value of the key to decrypt the message. Native libraries are inside the `lib` folder of the APK. It has the following structure.

```
├── lib
│   ├── arm64-v8a
│   │   └── libnative-lib.so
│   ├── armeabi-v7a
│   │   └── libnative-lib.so
│   ├── x86
│   │   └── libnative-lib.so
│   └── x86_64
│       └── libnative-lib.so
```

The lib is compiled for 4 different CPU architectures (arm32, arm64, x86 and x64).&#x20;

Opening the `libnative-lib.so` in IDA, we can see that it generates the string with the `NewStringUTF` method and that it depends on the `calculeLeMal` function.

<figure><img src="https://1813806532-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FZRRTPIEA4wb6exZozwS0%2Fuploads%2Ft80nd0bGaJu00YuJ2qCy%2Fneopasswd2_img3.png?alt=media&amp;token=02fb69d9-7b6c-4a80-9030-10ad75bfa731" alt=""><figcaption><p>getObfuscatedString</p></figcaption></figure>

I wanted to reverse it at first but, after taking a peek at its code, I chose a different approach.

<figure><img src="https://1813806532-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FZRRTPIEA4wb6exZozwS0%2Fuploads%2FtAjKeNqDH38vchi7aZSE%2Fneopasswd-img4.png?alt=media&amp;token=e925950a-3522-48bd-b35d-752daeb3904b" alt=""><figcaption><p>calculeLeMal</p></figcaption></figure>

Let's recap what we know:

* we can't be admin
* so we can't reach the `tryDecrypt` call
* even if we did reach it, the `getMaxAllowedLength` returns 3 so `tryDecrypt` would return `null`
* the key is computed using the `calculeLeMal` function of the `libnative-lib.so` library which would take a while to reverse

If we think about it, we don't care about the access control and the `getMaxAllowedLength` protection because since we know the encrypted base64 data `Mszhl+UnftsTwm7Ule0V28WQMptqd8uoc4AbDSBKavw=` and the used algorithm (`AES/ECB/PKCS5Padding`), **all we need is the key**.

We're gonna use [frida](https://frida.re/) to hook the application and display the return value of `getObfuscatedString`. Then we'll be able to decrypt the flag.

We already have the budtmo emulator running at `localhost:5555` (remember the docker command I used).

{% hint style="success" %}

```
docker run -d -p 6080:6080 -p 5554:5554 -p 5555:5555 -p 4723:4723 -e EMULATOR_DEVICE="Samsung Galaxy S10" -e WEB_VNC=true --device /dev/kvm --name android-container budtmo/docker-android:emulator_14.0_v2.16.2-p0
```

{% endhint %}

We need to connect to the emulated device with adb.

```sh
adb connect 127.0.0.1:5555
```

Then we install the app on the device.

```shell
adb install neopasswd2.apk
```

Good. Let's restart the adb server as root to use frida.

```shell
adb root
```

Now, install frida.

```shell
pip install frida-tools
```

Download frida-server on the [release page](https://github.com/frida/frida/releases) and extract the archive content.

{% hint style="danger" %}
We need to be cautious for this step and download the CPU architecture of our device. It can be seen with this command.

```shell
$ adb shell uname -m
x86_64
```

{% endhint %}

Now let's push it on the device at location `/data/local/tmp` and make it executable.

```shell
adb push frida-server-16.7.10-android-x86_64 /data/local/tmp
adb shell chmod +x /data/local/tmp/frida-server-16.7.10-android-x86_64
```

We can run the frida server now as a daemon in the background.

```shell
adb shell /data/local/tmp/frida-server-16.7.10-android-x86_64 -D &
```

{% hint style="success" %}
We can check that its correctly running and listening for the client with this command.

```shell
adb shell netcat -tupln
```

{% endhint %}

Great. Everything's in place.

Now we're gonna write a hook that gets executed when the `onCreate` method is called. The goal is to call `getObfuscatedString` and print its return value. This is done using the [Frida JavaScript API](https://frida.re/docs/javascript-api/).

```javascript
// hook.js
Java.perform(() => {
  const Activity = Java.use('com.example.neopasswd2.MainActivity');
  Activity.onCreate.implementation = function () {
    const key = this.getObfuscatedString();
    send('the key is ' + key);
  };
});
```

This hook just calls the method and prints the return value. It's worth noting that the original code of `onCreate` will not be executed, our code will. Since our hook does not call the original `onCreate` method, the application will likely crash after it's done but it won't matter since we'll already have the key.

We can start the app using frida to load the hook.

```bash
frida -U -f come.example.neopasswd2 -l hook.js
```

As expected an error message informs that something went wrong with the `onCreate` method but the message we wanted is already printed :) `the key is 74e0ab873df272a144f4545a7b6d566d`.

We can now decrypt the string.

```python
from base64 import b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

base64_encrypted = "Mszhl+UnftsTwm7Ule0V28WQMptqd8uoc4AbDSBKavw="
key_str = "74e0ab873df272a144f4545a7b6d566d"

key = key_str.encode("utf-8")  # 32-byte key for AES-256
encrypted_data = b64decode(base64_encrypted)

# Decrypt
cipher = AES.new(key, AES.MODE_ECB)
decrypted_padded = cipher.decrypt(encrypted_data)

# Unpad and decode
try:
    decrypted = unpad(decrypted_padded, AES.block_size)
    result = decrypted.decode("utf-8").strip()
    print("Decrypted text:", result)
except ValueError as e:
    print("Padding error or decryption failed:", e)
```

**Th3\_c4k3\_1s\_4\_L13!**
