Skip to content

Supply chain integrity bypass in updateWww via URL-only www.zip artifact identity #140

Description

@3em0

Submission Target

The repository does not contain a .github/ISSUE_TEMPLATE bug-report template or issue form. It does contain SECURITY.md, which explicitly says not to open public GitHub issues for security vulnerabilities.

Responsible disclosure target:

https://github.com/AidanPark/openclaw-android/security/advisories/new

Affected Versions

  • Confirmed affected: OpenClaw Android 0.4.0, commit ed83efa139083ec77f91a30b631e1c722f8e31a6
  • Other versions: Not verified
  • Fixed: Not available at the time of reporting
  • Introduced in: Not verified

Description

The Android www update path treats the www.zip download URL as the effective artifact identity. Remote component configuration supports url, version, and sha256, but UrlResolver.getWwwUrl() returns only www.url. JsBridge.updateWww() then downloads that URL, extracts the returned zip, replaces the local WebView frontend directory, and reloads the WebView without validating that the downloaded bytes match the expected sha256 or intended component version.

As a result, two www.zip artifacts with the same URL but different bytes, hashes, or versions are accepted equivalently. If an attacker can influence the bytes returned by the configured www.zip URL, the app installs and loads attacker-controlled WebView frontend code.

This is security-relevant because the WebView frontend is trusted local app UI. It is loaded from bootstrapManager.wwwDir, runs with JavaScript enabled, and has access to the OpenClaw JavaScript bridge.

Evidence

The remote component model includes url, version, and sha256:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/UrlResolver.kt#L78-L82
data class ComponentConfig(
    val url: String,
    val version: String?,
    @SerializedName("sha256") val sha256: String?,
)

However, the www resolver returns only the URL:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/UrlResolver.kt#L40-L43
suspend fun getWwwUrl(): String {
    val config = loadConfig()
    return config?.www?.url ?: BuildConfig.WWW_URL
}

checkForUpdates() uses the remote www.version only to decide whether to list an update. It does not bind the later download to that version:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/JsBridge.kt#L486-L497
val localWwwVersion =
    activity
        .getSharedPreferences("openclaw", 0)
        .getString("www_version", "0.0.0")
val remoteWwwVersion = ((config?.get("www") as? Map<*, *>)?.get("version") as? String)
if (remoteWwwVersion != null && remoteWwwVersion != localWwwVersion) {
    updates.add(
        mapOf(
            "component" to "www",
            "currentVersion" to (localWwwVersion ?: "0.0.0"),
            "newVersion" to remoteWwwVersion,

The update sink downloads, extracts, and applies the zip without computing or comparing a SHA-256 digest:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/JsBridge.kt#L570-L593
private suspend fun updateWww() {
    try {
        val url = UrlResolver(activity).getWwwUrl()
        val stagingWww = java.io.File(activity.cacheDir, "www-staging")
        stagingWww.deleteRecursively()
        stagingWww.mkdirs()

        emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...")
        val zipFile = java.io.File(activity.cacheDir, "www.zip")
        java.net.URL(url).openStream().use { input ->
            zipFile.outputStream().use { output -> input.copyTo(output) }
        }

        emitProgress("www", PROGRESS_EXTRACT, "Extracting...")
        extractZipToDir(zipFile, stagingWww)
        zipFile.delete()

        emitProgress("www", PROGRESS_APPLY, "Applying...")
        val wwwDir = bootstrapManager.wwwDir
        wwwDir.deleteRecursively()
        wwwDir.parentFile?.mkdirs()
        stagingWww.renameTo(wwwDir)

        activity.runOnUiThread { activity.reloadWebView() }

The installed wwwDir/index.html is loaded into the app WebView:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/MainActivity.kt#L163-L172
val wwwDir = bootstrapManager.wwwDir
val url =
    if (wwwDir.resolve("index.html").exists()) {
        "file://${wwwDir.absolutePath}/index.html"
    } else {
        // Load bundled fallback setup page from assets
        "file:///android_asset/www/index.html"
    }
AppLogger.i(TAG, "Loading WebView URL: $url")
binding.webView.loadUrl(url)

The WebView enables JavaScript and exposes the native bridge:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/MainActivity.kt#L130-L140
binding.webView.apply {
    clearCache(true)
    settings.javaScriptEnabled = true
    settings.domStorageEnabled = true
    settings.allowFileAccess = true
    @Suppress("DEPRECATION")
    settings.allowFileAccessFromFileURLs = true
    @Suppress("DEPRECATION")
    settings.allowUniversalAccessFromFileURLs = true
    settings.cacheMode = android.webkit.WebSettings.LOAD_NO_CACHE
    addJavascriptInterface(jsBridge, "OpenClaw")

The bridge exposes command-capable methods:

# https://github.com/AidanPark/openclaw-android/blob/ed83efa139083ec77f91a30b631e1c722f8e31a6/android/app/src/main/java/com/openclaw/android/JsBridge.kt#L440-L457
@JavascriptInterface
fun runCommand(cmd: String): String {
    val env = EnvironmentBuilder.build(activity)
    val result = CommandRunner.runSync(cmd, env, bootstrapManager.homeDir)
    return gson.toJson(result)
}

@JavascriptInterface
fun runCommandAsync(
    callbackId: String,
    cmd: String,
) {
    launchWithErrorHandling(
        errorEventType = "command_output",
        errorContext = mapOf("callbackId" to callbackId, "done" to true),
    ) {
        val env = EnvironmentBuilder.build(activity)

Steps to Reproduce

The following local end-to-end harness models the affected updateWww() logic without modifying the repository. It creates an official www.zip and records its trusted SHA-256, then serves an attacker-controlled www.zip from the same URL. The vulnerable flow uses only the URL, ignores the trusted hash, extracts the attacker zip, and installs it as the local WebView frontend.

#!/usr/bin/env python3
import hashlib
import http.server
import os
import pathlib
import shutil
import socketserver
import tempfile
import threading
import urllib.request
import zipfile

root = pathlib.Path(tempfile.mkdtemp(prefix="openclaw-www-e2e-"))
server_dir = root / "server"
cache_dir = root / "cache"
runtime_dir = root / "runtime"
www_dir = runtime_dir / "usr" / "share" / "openclaw-app" / "www"
for p in (server_dir, cache_dir, www_dir.parent):
    p.mkdir(parents=True, exist_ok=True)

def make_zip(path, entries):
    with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for name, data in entries.items():
            zf.writestr(name, data)

def sha256_file(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

legit_zip = root / "official-www-v2.1.0.zip"
evil_zip = server_dir / "www-v2.1.0.zip"

make_zip(legit_zip, {
    "index.html": b"<html><body><h1>Official OpenClaw UI</h1><script src='app.js'></script></body></html>",
    "app.js": b"window.__OPENCLAW_BUILD__='official';\n",
})
make_zip(evil_zip, {
    "index.html": b"<html><body><h1>Malicious UI</h1><script src='app.js'></script></body></html>",
    "app.js": b"window.__OPENCLAW_BUILD__='evil'; if (window.OpenClaw) OpenClaw.runCommand('id');\n",
})

trusted_sha256 = sha256_file(legit_zip)

class QuietHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass

class ReusableTCPServer(socketserver.TCPServer):
    allow_reuse_address = True

old_cwd = os.getcwd()
os.chdir(server_dir)
server = ReusableTCPServer(("127.0.0.1", 0), QuietHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()

try:
    url = f"http://127.0.0.1:{server.server_address[1]}/www-v2.1.0.zip"
    config = {"www": {"url": url, "version": "2.1.0", "sha256": trusted_sha256}}

    staging_www = cache_dir / "www-staging"
    shutil.rmtree(staging_www, ignore_errors=True)
    staging_www.mkdir(parents=True, exist_ok=True)

    zip_file = cache_dir / "www.zip"
    with urllib.request.urlopen(config["www"]["url"], timeout=5) as response:
        zip_file.write_bytes(response.read())

    downloaded_sha256 = sha256_file(zip_file)

    with zipfile.ZipFile(zip_file) as zf:
        zf.extractall(staging_www)
    zip_file.unlink()

    shutil.rmtree(www_dir, ignore_errors=True)
    www_dir.parent.mkdir(parents=True, exist_ok=True)
    staging_www.rename(www_dir)

    installed_index = (www_dir / "index.html").read_text()
    installed_js = (www_dir / "app.js").read_text()

    print("CONFIG_VERSION:", config["www"]["version"])
    print("TRUSTED_OFFICIAL_SHA256:", trusted_sha256)
    print("ACTUAL_DOWNLOADED_SHA256:", downloaded_sha256)
    print("HASH_MISMATCH:", downloaded_sha256 != config["www"]["sha256"])
    print("INSTALLED_INDEX_CONTAINS_MALICIOUS_UI:", "Malicious UI" in installed_index)
    print("INSTALLED_JS_CALLS_BRIDGE:", "OpenClaw.runCommand" in installed_js)
    print("WOULD_HAVE_BEEN_REJECTED_WITH_SHA256_CHECK:", downloaded_sha256 != trusted_sha256)
finally:
    server.shutdown()
    server.server_close()
    os.chdir(old_cwd)
    shutil.rmtree(root, ignore_errors=True)

Expected output:

CONFIG_VERSION: 2.1.0
HASH_MISMATCH: True
INSTALLED_INDEX_CONTAINS_MALICIOUS_UI: True
INSTALLED_JS_CALLS_BRIDGE: True
WOULD_HAVE_BEEN_REJECTED_WITH_SHA256_CHECK: True

Impact Assessment

This vulnerability allows an attacker who can influence the bytes returned by the configured www.zip artifact URL to:

  • Bypass intended WebView frontend artifact integrity checks because www.sha256 is not used.
  • Replace the trusted local WebView frontend with attacker-controlled HTML and JavaScript.
  • Reach native app functionality exposed to the trusted frontend through the OpenClaw JavaScript bridge.

This is primarily a supply-chain attack. HTTPS reduces ordinary network man-in-the-middle risk, but it does not protect against compromised release assets, CDN/origin compromise, poisoned mirrors, malicious enterprise TLS roots, or artifact-distribution failures. The attacker does not need to modify the app's private files directly or already have JavaScript execution in the WebView; those effects are reached through the unverified update path.

The practical impact is confined to the Android app sandbox and the OpenClaw/Termux runtime unless combined with another escape.

Suggested Fix

Recommended changes:

  1. Return full www component metadata from UrlResolver, not only url.
  2. Require a non-empty, well-formed sha256 for network-downloaded www.zip artifacts.
  3. Download www.zip to a temporary file and compute SHA-256 over the exact downloaded bytes before extraction.
  4. Reject the archive if the computed digest does not exactly match the trusted expected www.sha256.
  5. Bind the applied local www_version to the verified component version after successful digest validation.
  6. Consider signing remote config or pinning trusted artifact metadata in the APK so an attacker who controls both config and artifact bytes cannot update the malicious hash as well.
  7. Validate zip entry paths before extraction so archive entries cannot escape the staging directory.

Reporter Notes

No public GitHub issue should be opened for this report because SECURITY.md explicitly asks reporters not to disclose security vulnerabilities through public issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions