DEV Community

Cover image for I Traced MaxKB's 10.0 CVE Through the Advisory and the Code
Kiell Tampubolon
Kiell Tampubolon

Posted on

I Traced MaxKB's 10.0 CVE Through the Advisory and the Code

On Sep 21 a CVSS 10.0 landed on NVD for MaxKB, an open-source agent platform with about 22,900 GitHub stars built on Python, Django and LangChain. The advisory description reads differently from most: an agent's shell tool "exposes an execute shell tool without excluding it and omits execute from interrupt_on, so human approval is not required". Untrusted chat or ingested content can therefore become command execution inside the official container.

That alone would be a normal patch-now story. What kept me reading was the mismatch I found when I pulled the fix commit and the tagged release source: the change that actually shipped in v2.10.5-lts is a shell-quoting fix, and the approval-gate line in tools.py still does not name the execute tool at that tag. Meanwhile three sibling CVEs sit marked "no patched version". I walked the chain from advisory to commit to release tag. Here is what holds up, what does not, and the three checks I would run before trusting any agent platform's "fixed" label.

First, the record as it returned for me on Sep 22. Re-run it yourself, records move:

curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2026-77521" \
  | python -c "import json,sys; v=json.load(sys.stdin)['vulnerabilities'][0]['cve']; \
print(v['published'], v['metrics']['cvssMetricV31'][0]['cvssData']['baseScore'])"
Enter fullscreen mode Exit fullscreen mode
2026-09-21T21:17:10.943 10.0
Enter fullscreen mode Exit fullscreen mode

How does a chat message become a shell command?

Per the advisory description, the path is narrow but real. MaxKB agents can carry tools, MCP tools, skills, or sub-applications. When an assistant carries any of those, the flow backend is SandboxShellBackend, which exposes an execute shell tool to the agent. The description says that tool is not excluded from the agent's toolset and is not listed in interrupt_on, the structure the underlying framework uses to decide when to pause and ask a human. So nothing pauses.

The input side is what Lasso Security's write-up (the discoverer, Noy Pearl, Sep 10) fills in: knowledge-base content. MaxKB ingests documents and crawls pages, and ingested text is model context, which means it is prompt-injectable. I have argued before that a planted prompt is a credential (MCP 2026-07-28 Went Stateless: A Planted Prompt Is a Credential); here the planted prompt sits one tool call away from a shell.

Then the environment decides how bad it gets. The description names two cases. Source deployments with the MAXKB_SANDBOX flag disabled run commands directly as the application user. The official root container wraps commands with a string-based gosu wrapper, and Lasso demonstrated that metacharacters like ;, |, $() and > could escape it into the outer root shell. Their title says it plainly: MaxKBypass.

What did the fix in v2.10.5-lts actually change?

The fix commit is 594f50f2, authored July 9, one file touched, plus 241 minus 5 lines. It replaces the string wrapper with a parse-then-wrap design: split the command into tokens with shlex.split, rebuild the sandbox command by re-quoting each token with shlex.quote, and refuse to run anything that does not parse.

# shape of the fix in apps/application/flow/backend/sandbox_shell.py
# (commit 594f50f2; read the commit for the exact code)
try:
    tokens = shlex.split(command)             # parse first
    wrapped = _build_sandbox_command(tokens)  # re-quote every token
except ValueError as e:
    return ExecuteResponse(output=f"Invalid command: {e}", exit_code=1)
Enter fullscreen mode Exit fullscreen mode

This is the right fix class and it is worth naming. The old code composed a shell string and trusted the composition. The new code parses, then quotes. It is the same lesson as the write-sink bug I traced in my own scanner's target (My MCP Security Scanner Missed 2026's Worst MCP RCE: Here Is the One-Rule Fix): if you build a command from parts, quote at the boundary, not in the middle.

Why does the advisory's approval gate still not appear at that tag?

Here is the part that made me write this post. The advisory description leads with a missing approval gate: execute is omitted from interrupt_on, "so human approval is not required". The fix that shipped addresses quoting, not gating. I fetched tools.py at the v2.10.5-lts tag and the agent construction at that tag includes (abridged):

# tools.py @ v2.10.5-lts (abridged; the interrupt_on line is the verified fragment)
agent = create_deep_agent(
    model=chat_model,
    backend=SandboxShellBackend(root_dir=temp_dir, virtual_mode=True),
    skills=["/skills"],
    tools=tools,
    system_prompt=system_prompt,
    interrupt_on={"write_file": False, "read_file": False, "edit_file": False},
    checkpointer=checkpointer,
)
Enter fullscreen mode Exit fullscreen mode

No execute key. No excluded_tools.

Now the honest hedge, because this is exactly where I could be wrong. I read source at one tag, I did not run the platform, and I cannot see how the deep-agent framework treats a missing key in interrupt_on. The advisory says the issue is fixed in v2.10.5-lts and I have no evidence against that claim. Both facts sit next to each other: the quoting hole the researcher demonstrated got closed, and the gate the description names is absent from the config line at the same tag. Whether the remaining combination leaves a real gap is precisely what I cannot verify from source alone. Treat "fixed" as the vendor's claim, the commit as the evidence, and the absent gate as a question worth asking in your own deployment.

Which three sibling CVEs are still marked without a fix?

The same advisory batch shipped three medium-severity records alongside the 10.0, and their status is where the story gets uncomfortable.

CVE Score What breaks Advisory status
CVE-2026-77516 5.4 A tool denied to a workspace member can still be bound through tool_ids, skill_tool_ids or mcp_tool_ids and executed via the dispatch path; execution decrypts server-side init_params, so the caller receives the denied tool's credentials "No fixed version ... as of this review"
CVE-2026-77518 5.0 The tool-detail route skips per-resource authorization, so knowing a tool_id returns Tool.code, which may carry MCP server config and headers; an attacker-owned workflow can reference a foreign mcp_tool_id "No patched version"
CVE-2026-77519 5.4 The /chat/api/mcp auth path checks only the secret and active status, skipping is_permanent and expire_time, so expired non-permanent keys still initialize MCP and invoke tools/call "No fixed version ... as of this review"

Here is the twist. The v2.10.4-lts release notes from July 16 claim, verbatim: "Added independent authorization verification mechanism for agent and workflow tool scheduling", "Fixed horizontal privilege escalation caused by defective MCP tool authorization logic", and "Fixed expired agent API keys being usable at the /chat/api/mcp endpoint". Those map cleanly onto the three siblings. So the release notes say fixed, the advisories say no patched version as of review, and I have no runtime evidence for either side. I am presenting that as unresolved and treating the advisories as the conservative read. If you need a tiebreaker, it is a question for the vendor, not something I can settle from here.

If you run MaxKB, what can you check today?

Three checks. They are record-derived, not lab-run against a live instance, so treat the outputs below as shapes to expect rather than captured truth.

Check 1: pin the version, then read past the headline

The two sources disagree on the affected range: the GHSA says <= 2.10.3-lts, NVD says < 2.10.5-lts. v2.10.5-lts shipped Aug 6 and v2.10.6-lts on Sep 3 is current. Check what you actually run:

docker ps --format '{{.Image}}' | grep -i maxkb
# compare the tag against BOTH ranges:
# GHSA-f36j-f34j-h3rx says vulnerable <= 2.10.3-lts
# NVD says vulnerable < 2.10.5-lts
Enter fullscreen mode Exit fullscreen mode

Check 2: ask what your sandbox flag is really doing

Per the advisory description, source deployments with MAXKB_SANDBOX disabled run commands directly as the application user. If you self-host from source, that environment variable is the whole boundary between the agent and your host. It deserves the same status as a firewall rule, meaning it gets reviewed, not assumed.

Check 3: audit the dispatch path, not just the tool list

The sibling pattern is the transferable lesson: a deny applied at one route does not propagate to dispatch. The same failure shape exists in any agent stack, including the ones you built yourself. The sketch below is adapted from the MaxKB advisory patterns to a generic registry; it is illustrative and unrun:

# illustrative audit sketch, adapted from the MaxKB advisory patterns
def audit(tool, grants):
    if tool.runs_shell and not tool.human_approval:
        yield f"{tool.name}: shell tool with no approval gate"
    for g in grants:
        if g.denied and g.id in tool.dispatch_ids:
            yield f"{tool.name}: denied {g.id} still dispatchable"
    if {"headers", "init_params"} & set(tool.detail_fields):
        yield f"{tool.name}: detail route leaks config"
Enter fullscreen mode Exit fullscreen mode

Why would release notes skip the fix that matches the 10.0?

A short governance observation. The paper trail here is scattered: the fix commit on July 9, the fixed release on August 6 with notes that list four other security fixes, four advisories on September 2, and NVD indexing on September 21. If your patch SLA triggers on release notes, this CVE never appears. If it triggers on advisory publication, the fix already existed for almost a month. Neither workflow surfaces the full picture; only reading the diff does. I flagged the same pattern in the agent-sandbox CVE pair from earlier this month (2 CVSS 9.8 Agent Sandbox CVEs Landed the Same Day): defaults and documentation lag the actual boundary.

Is "fixed in v2.10.5-lts" the whole truth, or half of it?

This is the part I want to argue about. The advisory description leads with a missing approval gate. The shipped fix is a quoting change. The gate the description names does not appear in the config line at the fixed tag. If you were the maintainer, would you ship the quoting fix and call the CVE closed, or hold the release until the gate exists?

And the sharper version: should an agent runtime refuse to offer a shell tool when no approval gate is configured, the way MaxKB could refuse to construct SandboxShellBackend unless interrupt_on names execute? Fail-closed costs convenience. Fail-open costs root. I keep landing on fail-closed for anything that can execute commands, but I run small deployments where the convenience loss is cheap. Tell me where your line is, because I have not settled mine.

What should you take away from this?

  • A 10.0 here needed three ingredients stacked: untrusted input, a shell tool with no gate, and a sandbox that was one string operation.
  • Parse-then-quote beats compose-then-trust, and the fix commit is the evidence, not the release notes.
  • Advisories can name a gate the fix does not build. Read the diff.
  • Authorization has to be re-checked at dispatch time; a deny at one route is not a deny everywhere.
  • My three checks are derived from records, not a lab. Run them on your own deployment, and re-pull the NVD records before you act: the sibling status may have changed by the time you read this.

Top comments (0)