| Description | ## 1. Basic Information
- Product: ToonFlow
- Version: 1.1.8
- Code revision: `b344a736599203e874984660f080cce9cb884795`
- Affected platform: Windows production Electron client
- Vulnerability type: Operating system command injection / remote code execution
- Attack prerequisites: The target user is running the ToonFlow Windows client and visits an attacker-controlled webpage in a browser on the same computer; the client still uses the default administrator account `admin/admin123`, or the attacker has obtained a valid login token
- Severity: High
## 2. Vulnerability Description
When the ToonFlow Electron client starts, it creates an HTTP service on the local computer. The service enables CORS for arbitrary origins and initializes the database with a default administrator account, `admin/admin123`. Consequently, if a user is running the client and visits a malicious webpage, JavaScript in that page can scan local ports, identify the ToonFlow service, sign in with the default credentials, and invoke the "open folder" endpoint.
On Windows, that endpoint inserts the attacker-controlled `path` parameter into the string `explorer "${target}"` and passes the result to `child_process.exec()`. By adding double quotes, `&`, and other cmd metacharacters to `path`, an attacker can break out of the intended `explorer` argument and execute arbitrary system commands. The commands run with the privileges of the user running ToonFlow and can be used to read, write, or delete files, download data, and execute malicious programs.
The use of a random port does not meaningfully mitigate the issue. The production Electron client starts the HTTP service with port `0`, so the operating system assigns an ephemeral port. A malicious webpage can scan the ephemeral port range on `127.0.0.1` and identify the service using a response fingerprint. Because the service returns `Access-Control-Allow-Origin: *`, a browser-based webpage can read the responses from the identification and login endpoints.
## 3. Vulnerability Details
### 3.1 The production client starts a browser-accessible local HTTP service
The Electron main process loads the backend service and passes `randomPort = true`:
```ts
// scripts/main.ts
const port = await mod.default(true);
process.env.PORT = port;
```
The backend calls `server.listen(port)` without specifying a bind address. The service therefore listens on all network interfaces by default and can be reached through `127.0.0.1`:
```ts
// src/app.ts
const port = randomPort ? 0 : 10588;
return await new Promise((resolve) => {
server.listen(port, async () => {
const address = server.address();
const realPort = typeof address === "string" ? address : address?.port;
resolve(realPort);
});
});
```
Both Express and Socket.IO allow cross-origin requests from arbitrary origins:
```ts
// src/app.ts
app.use(cors({ origin: "*" }));
```
```ts
// src/app.ts
const io = new Server(server, { cors: { origin: "*" } });
```
This allows a malicious webpage not only to send requests to the local service, but also to read their responses. The page can therefore complete port identification and parse the login result.
### 3.2 Default administrator credentials weaken the authentication boundary
Database initialization creates a default account when the `o_user` table does not exist:
```ts
// src/lib/initDB.ts
{
name: "o_user",
builder: (table) => {
table.integer("id").notNullable();
table.text("name");
table.text("password");
table.primary(["id"]);
table.unique(["id"]);
},
initData: async (knex) => {
await knex("o_user").insert([{ id: 1, name: "admin", password: "admin123" }]);
},
}
```
The login endpoint only compares the plaintext password stored in the database:
```ts
// src/routes/login/login.ts
const data = await u.db("o_user").where("name", "=", username).first();
if (data!.password == password && data!.name == username) {
const tokenData = await u.db("o_setting").where("key", "tokenKey").first();
const token = setToken(
{ id: data!.id, name: data!.name },
"180Days",
tokenData?.value as string,
);
return res.status(200).send(success({ token: "Bearer " + token, ... }));
}
```
For a new installation, or a user who upgraded from the default credentials, a malicious webpage can sign in directly and obtain a JWT valid for 180 days. If the user has changed the default password, the attacker needs valid credentials or a token to invoke the vulnerable endpoint.
### 3.3 The "open folder" endpoint concatenates a shell command
The vulnerable code is:
```ts
// src/routes/setting/fileManagement/openFolder.ts
import { exec } from "child_process";
export default router.post(
"/",
validateFields({
path: z.string(),
}),
async (req, res) => {
if (!isEletron()) {
return res.status(400).send(error("Opening folders is supported only in the client"));
}
const { path: folderPath } = req.body;
const platform = process.platform;
const target = u.getPath(folderPath);
const cmd = platform === "win32"
? `explorer "${target}"`
: platform === "darwin"
? `open "${target}"`
: `xdg-open "${target}"`;
exec(cmd, (err) => {
...
});
},
);
```
`u.getPath()` is intended to restrict paths to the application data directory:
```ts
// src/utils/getPath.ts
export default (fileName?: string[] | string) => {
let basePath: string;
if (typeof process.versions?.electron !== "undefined") {
const { app } = require("electron");
const userDataDir: string = app.getPath("userData");
basePath = path.join(userDataDir, "data");
} else {
basePath = path.join(process.cwd(), "data");
}
if (fileName) {
const dbPath = Array.isArray(fileName)
? path.resolve(basePath, ...fileName)
: path.resolve(basePath, fileName);
if (!isPathInside(dbPath, basePath) && dbPath !== basePath) {
throw new Error("Path escape error: the path must remain inside the data directory");
}
return dbPath;
}
return basePath;
};
```
The problem is that this check only verifies whether the final path is inside the data directory. It does not reject shell metacharacters in the path. If the attacker-controlled value begins with a normal filename, it resolves into the data directory and passes the check, while double quotes and `&` remain intact.
For example, the attacker can send:
```text
poc" & powershell -NoProfile -EncodedCommand <base64> & rem "
```
`u.getPath()` returns a value such as:
```text
C:\Users\<user>\AppData\Roaming\toonflow\data\poc" & powershell -NoProfile -EncodedCommand <base64> & rem "
```
The endpoint then constructs:
```text
explorer "C:\Users\<user>\AppData\Roaming\toonflow\data\poc" & powershell -NoProfile -EncodedCommand <base64> & rem ""
```
On Windows, `child_process.exec()` runs the command through a shell. The first double quote closes the argument intended for `explorer`; the first `&` acts as a cmd command separator, causing `powershell` to run as a second command; and the trailing `rem "` comments out the second quote appended by the application. The `path` parameter is therefore not a filesystem path, but a command injection payload.
### 3.4 The static file service can be used to retrieve command output
The application mounts `data/oss` as a static file directory:
```ts
// src/app.ts
const ossDir = u.getPath("oss");
app.use(
"/oss",
...,
express.static(ossDir, { acceptRanges: false }),
);
```
This mount occurs before the authentication middleware, so files under `/oss/...` are directly accessible. The reproduction code makes the injected PowerShell command write its output to `data/oss/poc-rce.txt`. The webpage then reads `http://127.0.0.1:<port>/oss/poc-rce.txt`, confirming that an arbitrary command executed and retrieving its output.
Output retrieval is only a verification technique. Even without reading the output, an attacker can directly execute a downloader, write to a startup location, modify files, or perform other malicious actions.
## 4. Reproduction
### 4.1 Reproduction steps
1. Start the ToonFlow production client on Windows and keep it running.
2. Save the complete reproduction page below as any `.html` file and open it in a browser on the same computer.
3. Keep Host set to `127.0.0.1` and keep the default command `whoami`.
4. Click `Find listening port`. The page first checks fixed port `10588`, then scans the Windows ephemeral port range `49152-65535`. If the service is not found, select the full-port scan option.
5. Click `Execute command`. The page signs in with the default account, constructs a `path` value containing PowerShell, and invokes the `openFolder` endpoint.
6. The page then reads `/oss/poc-rce.txt`. If the output contains the current Windows username, arbitrary command execution is confirmed.
If the client's default password has been changed, the login step fails. In that case, replace the default credentials in the reprodu |
|---|