Latitude of Injection: Mobile (Android)
On this page
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:
- The city the app is restricted to
- 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:
- Queries
SELECT name, email FROM users WHERE is_admin = 1 - 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 returnsnull→ shows “No privileged info found.” - Otherwise it sets
privilegedInfoto 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.xmlfor 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.
jadxwith--no-res(sources only) and--no-src(resources only) runs faster than a full decompile when you only need one half.