Unauthenticated LAN attacker can achieve arbitrary code execution on any SoloPi Android device via the unauthenticated control HTTP server and unsigned dynamic patch loader
- Langage dominant
- Java
- Étoiles
- 6.3k
- Forks
- 1.3k
- Métriques de merge des PR
- Aucune PR mergée en 30 j
Description
version: v0.12.0
### Summary
SoloPi starts an embedded NanoHTTPD server on every device it runs on, listening on TCP port 23342 (configurable, default 23342) on all network interfaces, with no authentication of any kind. This server exposes a generic "scheme" bridge that lets any caller invoke internal app actions by name, including a `config` action that can set arbitrary application settings — among them `KEY_PATCH_URL`, the URL SoloPi uses to fetch a "hot patch" manifest and dynamically load and execute Java/DEX code into its own process. The patch loader's only integrity check is an MD5 hash that is supplied by the same untrusted manifest/zip the attacker controls, i.e. it is not a real integrity check at all. Because `PatchRequest.updatePatchList()` is invoked unconditionally every time the app's home screen (`IndexActivity`) is opened, an attacker who can merely send one unauthenticated HTTP GET to the device's control port can plant a malicious patch URL and obtain arbitrary code execution in the SoloPi process — with all of SoloPi's granted permissions (storage, ADB debugging bridge, accessibility service, screen capture, etc.) — the next time anyone opens the app.
### Details
**1. Unauthenticated control HTTP server, bound to all interfaces**
`src/common/src/main/java/com/alipay/hulu/common/http/HttpServer.java` (extends NanoHTTPD):
```java
public HttpServer(int port) {
super(port);
listeners = new ArrayList<>();
}
...
@Override
public Response serve(IHTTPSession session) {
String url = session.getUri();
Map requestParam = session.getParms();
if (Method.POST.equals(session.getMethod())) {
...
} else {
for (OnUrlRequestListener listener: listeners) {
Response res = listener.onGet(url, requestParam);
if (res != null) {
return res;
}
}
return NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/html", FAIL_RESPONSE_NO_PROCESS);
}
}
```
There is no session/token/credential check anywhere in this class. `src/common/src/main/java/com/alipay/hulu/common/application/LauncherApplication.java:275` starts it automatically 5 seconds after app launch:
```java
startHttpServerAtPort(SPService.getInt(SPService.KEY_CONTROL_PORT, 23342));
schemeListener = new SchemeHttpListener();
registerControlListener(schemeListener);
```
`NanoHTTPD`'s default constructor (`super(port)`, no hostname argument) binds to the wildcard address, confirmed empirically: `netstat` on the running device shows `tcp6 0 0 :::23342 :::* LISTEN`.
**2. The `config` scheme lets any caller set `KEY_PATCH_URL`**
`src/common/src/main/java/com/alipay/hulu/common/scheme/SchemeHttpListener.java` bridges `GET /scheme/?` straight into the app's internal scheme-resolver dispatch with no auth check:
```java
public static final String LISTEN_PATH = "/scheme/";
@Override
public NanoHTTPD.Response onGet(String url, Map params) {
if (StringUtil.startWith(url, LISTEN_PATH)) {
String realPath = url.substring(LISTEN_PATH.length());
return doSchemeJump(LauncherApplication.getInstance().getSchemeResolver(), realPath, params);
}
return null;
}
```
`src/app/src/main/java/com/alipay/hulu/scheme/ConfigSchemeResolver.java:81-86` handles action `config` and, among other keys, writes `KEY_PATCH_URL` straight to SharedPreferences from the untrusted request:
```java
case KEY_ADB_SERVER:
case KEY_PATCH_URL:
case KEY_PERFORMANCE_UPLOAD:
LogUtil.i(TAG, "Update Config " + key + " to value " + value);
SPService.putString(key, value);
break;
```
**3. The patch URL is fetched and installed automatically on every app launch**
`src/app/src/main/java/com/alipay/hulu/activity/IndexActivity.java:244` — unconditionally, in `onCreate` of the app's home screen:
```java
PatchRequest.updatePatchList(null);
```
`src/app/src/main/java/com/alipay/hulu/upgrade/PatchRequest.java:69-101`:
```java
public static void updatePatchList(final LoadPatchCallback callback) {
String storedUrl = SPService.getString(SPService.KEY_PATCH_URL,
"https://raw.githubusercontent.com/alipay/SoloPi/master/.json");
...
HttpUtil.get(realUrl, new HttpUtil.Callback(PatchResponse.class) {
@Override
public void onResponse(Call call, PatchResponse item) throws IOException {
doUpgradePatch(item);
...
```
`doUpgradePatch()` downloads and installs any manifest entry of `type: "base"` or `type: "required"` unconditionally (no signature/allowlist check on the URL host).
**4. The only "integrity check" on the downloaded code is self-referential**
`src/common/src/main/java/com/alipay/hulu/common/utils/PatchProcessUtil.java:118-121`:
```java
String jar = patchDesc.getJar();
String jarMd5 = patchDesc.getJarMd5();
if (!StringUtil.isEmpty(jar)) {
File jarFile = new File(outFolder, jar);
if (!FileUtils.checkFileMd5(jarFile, jarMd5)) {
throw new PatchProcessException("Jar File " + jarFile + "doesn't match its md5", outFolder);
}
```
Both the jar file and its declared MD5 (`jarMd5`) come from the same attacker-hosted zip — the attacker simply computes the MD5 of their own malicious jar and puts it in `desc.json`. There is no code signing, no pinned public key, and no allowlist of trusted hosts anywhere in this flow.
**5. The loaded code executes automatically via the service-registration mechanism**
`ClassUtil.installPatch()` (`src/common/src/main/java/com/alipay/hulu/common/utils/ClassUtil.java:649-697`) calls `loadPatch()` (loads every class in the dropped DEX via a custom `PatchClassLoader`) and then:
```java
// 最后再更新Patch中的Services信息
LauncherApplication.getInstance().registerPatchServices(patch);
```
`LauncherApplication.registerPatchServices()` (line 477) scans the patch's classes for implementations of `ExportService` annotated `@LocalService`, and for any non-lazy one, instantiates it and calls `onCreate(context)` immediately — i.e. attacker-supplied code runs with no further trigger needed.
### PoC
(available upon request)
### Impact
Any host that can route a single unauthenticated HTTP request to a SoloPi-running device's port 23342 (same Wi-Fi network, shared test-lab LAN, USB-tethered network, hostile access point, etc.) can obtain persistent arbitrary code execution inside the SoloPi app process the next time the device owner opens the app — which the app's own onboarding flow already prompts the user to do. Because SoloPi is a UI-automation and device-control tool, its process typically holds storage, Accessibility Service, and ADB-adjacent capabilities, so code execution here yields the ability to read/write app data, drive the Accessibility Service to interact with arbitrary other apps on the device (including whatever the device is used to test, which for an Ant Financial-authored QA tool plausibly includes financial/payment apps), capture the screen, and persist by re-registering itself as a `@LocalService` on every future launch. No authentication, no social engineering beyond normal app use, and only network-adjacency to the victim device are required.
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.