Latitude of Injection: Mobile (Android)

Flag: KPMG_CTF{flag generated by platform from city + privileged info} Package: com.ctf.loi File: loi_v1.apk (27 MB) Category: Mobile / Android

Brief

The app is called “Latitude of Injection”, a hint at both its location check and its vulnerability. To get the flag, the platform needs two pieces of information:

  1. The city the app is restricted to
  2. The privileged info shown on the Admin Panel

Step 1: Triage the APK

$ file loi_v1.apk
loi_v1.apk: Zip archive data

$ mkdir unz && unzip -q loi_v1.apk -d unz
$ ls unz
AndroidManifest.xml  assets  classes.dex  classes2.dex  classes3.dex
classes4.dex  classes5.dex  kotlin  lib  META-INF  res  resources.arsc

Five dex files. The .text code is in classes.dex (18 MB). The manifest lists:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET"/>

GPS permissions are the first clue: location is central to the challenge.

Step 2: Decompile with jadx

$ jadx -d jx --no-res -j 8 loi_v1.apk   # decompile sources
$ jadx -d jr --no-src loi_v1.apk         # decode resources

App source lives under jx/sources/com/ctf/loi/. Key files:

File Purpose
MainActivity.java Entry point; reads location config from resources
LocalDbHelper.java SQLite helper; seeds users, handles login
ScreensKt.java Compose UI screens (Home, Login, Signup, Admin)
Utils.java Haversine distance helper; GPS location fetch

Step 3: Find the allowed city (strings.xml)

$ cat jr/resources/res/values/strings.xml | grep -E 'l1|l2|al_r|app_name'
<string name="app_name">Latitude of Injection</string>
<string name="l1">55.7558</string>
<string name="l2">37.6173</string>
<string name="al_r">300000.0</string>

MainActivity reads these at startup:

double allowedLat    = Double.parseDouble(getString(R.string.l1));   // 55.7558
double allowedLon    = Double.parseDouble(getString(R.string.l2));   // 37.6173
double allowedRadius = Double.parseDouble(getString(R.string.al_r)); // 300 000 m

Utils.distanceMeters() is a standard Haversine formula. If the device’s GPS puts it within 300 km of (55.7558 N, 37.6173 E) the Home screen shows “Location allowed”, otherwise “Services not available in your country.”

55.7558 N, 37.6173 E is the centre of Moscow, Russia.

Step 4: Understand the user/admin model

LocalDbHelper.onCreate() creates one table and two seed rows:

db.execSQL("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
           "name TEXT, email TEXT, is_admin INTEGER DEFAULT 0)");
db.execSQL("INSERT INTO users (name, email, is_admin) VALUES ('alice', 'alice@example.com', 0)");
db.execSQL("INSERT INTO users (name, email, is_admin) VALUES ('bob',   'bob@example.com',   0)");

seedAdmin() is called on first launch and inserts an admin row only if none exists:

contentValues.put("name",     "");           // empty name
contentValues.put("email",    "admin@local");
contentValues.put("is_admin", 1);

The Login screen calls safeFindByName(name), which uses a parameterized query:

rawQuery("SELECT id,name,email,is_admin FROM users WHERE LOWER(name) = ? LIMIT 1",
         new String[]{name.toLowerCase()});

If the returned row has is_admin == 1 the app navigates to the Admin Panel.

Step 5: Find the vulnerability (createUser)

The Signup screen calls LocalDbHelper.createUser(name, email), which concatenates user input directly into SQL and logs the raw statement:

String sql = "INSERT INTO users (name, email, is_admin) VALUES ('"
           + name + "', '" + email + "', 0);";
Log.i("LocalDbHelper", "VULN_INSERT: " + sql);
db.execSQL(sql);

This is a classic SQL injection point. Supplying a crafted name value that closes the VALUES clause early and adds a second INSERT can create an admin-level user, which the parameterized safeFindByName will then return as a valid admin login.

Step 6: Find the privileged info

Once logged in as admin, ScreensKt$AdminScreen launches a coroutine that:

  1. Queries SELECT name, email FROM users WHERE is_admin = 1
  2. If the row’s name and email match the placeholders "admin" / "admin@local" (i.e. the seeded admin row still has an empty name), it returns null → shows “No privileged info found.”
  3. Otherwise it sets privilegedInfo to the admin’s email value

The Admin Panel also immediately sets privilegedInfo via:

this.$ctx.getString(R.string.not_required7)

not_required7 in strings.xml:

<string name="not_required7">ckdjN340OXQ3V1dKfmhNZjc3</string>

Decode it:

$ echo ckdjN340OXQ3V1dKfmhNZjc3 | base64 -d
rGc7~49t7WWJ~hMf77

Privileged info: rGc7~49t7WWJ~hMf77

Summary

Question Answer
Allowed city Moscow (55.7558 N, 37.6173 E, ±300 km)
Privileged info (raw resource) ckdjN340OXQ3V1dKfmhNZjc3
Privileged info (decoded) rGc7~49t7WWJ~hMf77
Vulnerability SQL injection in createUser() (raw string concat)
Safe path safeFindByName() uses parameterized query

Submit the city (Moscow) and the privileged info to the CTF platform to receive the generated flag.

Takeaways

  • Always check res/values/strings.xml for hardcoded coordinates, keys or secrets; they survive decompilation intact.
  • Mixing parameterized queries (safeFindByName) with raw concatenation (createUser) in the same codebase is a common real-world mistake; audit every DB call independently.
  • GPS restrictions are client-side; the server (or in this case the platform) derives the flag from values you submit, not from your actual location.
  • jadx with --no-res (sources only) and --no-src (resources only) runs faster than a full decompile when you only need one half.