Back to skill

Security audit

MarkItDown

Security checks for vulnerabilities and agentic risk

Overview

The skill is a real Markdown conversion utility, but Review is warranted because its browser fallback can let untrusted public pages make internal-network requests and its opt-in reporting sends more local metadata than clearly disclosed.

Use this skill only for public URLs and local files you intend to convert. In shared, cloud, enterprise, or sensitive networks, disable browser fallback with --browser-fallback=off unless the browser runs in a network-isolated container, and do not use --allow-internal. Leave SAVINGS_URL unset for private work unless you trust the receiver and are comfortable sending source paths or URLs plus limited local execution metadata.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (2)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/url_fetch.py:158
Finding

Headless browser fallback allows SSRF through unfiltered navigation and subresources

Content
View full analysis

Vulnerability Details

File Location: scripts/url_fetch.py:158-170
Vulnerability Type: Browser-mediated server-side request forgery
Risk Level: High

Related call site: scripts/url_to_markdown.py:375-382

Vulnerable code:

python
base = [browser, "--headless=new", "--disable-gpu",
        f"--virtual-time-budget={virtual_time}", "--dump-dom", url]
if pin_rule:
    base.insert(1, "--host-resolver-rules=%s" % pin_rule)
is_root = hasattr(os, "getuid") and os.getuid() == 0
if is_root:
    attempts = [base + ["--no-sandbox"]]
else:
    attempts = [base, base + ["--no-sandbox"]]
last_err = None
try:
    for i, cmd in enumerate(attempts):
        try:
            with open(html_path, "w", encoding="utf-8", errors="ignore") as fh:
                subprocess.run(
                    cmd, stdout=fh, stderr=subprocess.DEVNULL, timeout=90,
                    check=True,
                )
python
browser = None if args.no_browser else find_browser()
fallback_md = ""
if browser:
    ...
    html = render_with_browser(args.url, browser, args.virtual_time_budget,
                               allow_internal=args.allow_internal)

Technical Analysis

The converter validates and pins the user-supplied top-level URL before launching Chromium. However, the resolver rule only covers the original hostname, and Chromium is launched without request interception or network isolation.

Once Chromium executes an attacker-controlled public page, that page can initiate additional requests through JavaScript, frames, images, stylesheets, or other browser resource mechanisms. Those secondary destinations are not passed through url_security.py. The page can therefore direct Chromium to loopback, private-network, link-local, or cloud-metadata addresses even though the original public URL passed the SSRF guard.

The browser fallback is reachable when the initial direct fetch p ...[truncated 1914 chars]

Remediation
View remediation

Remediation Suggestions

  1. Intercept every Chromium request and navigation, not only the initial URL. Validate each destination using the same private, loopback, link-local, reserved, multicast, and CGNAT checks implemented in url_security.py.
  2. Block non-HTTP and non-HTTPS schemes and re-resolve each hostname immediately before connection.
  3. Run the browser in an isolated network namespace or container with no route to internal networks, loopback services outside the sandbox, or cloud metadata.
  4. Alternatively, route browser traffic through a filtering proxy that validates and pins every destination, redirect, frame, and subresource.
  5. Revalidate the browser's final committed top-level URL before accepting or converting the dumped DOM.
  6. Disable browser fallback by default until complete request filtering is available, or require explicit user approval after clearly describing the internal-network exposure.
  7. Add regression tests covering JavaScript navigation, iframe loads, image requests, redirects, IPv4 and IPv6 private addresses, alternate IP representations, and cloud-metadata destinations.

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/report_savings.py:178
Finding

Statistics reporting sends undisclosed source and local execution metadata

Content
View full analysis

Vulnerability Details

File Location: scripts/report_savings.py:178-227
Vulnerability Type: Sensitive metadata disclosure beyond the documented telemetry scope
Risk Level: Medium

Network transmission sink: scripts/report_savings.py:168-175

Vulnerable code:

python
def caller_info():
    """调用方归因信息(2026-09-14 加)。

    背景:2026-09-13 有 5 笔来源不明的上报(OpenClaw / Hermes 会话库均查不到),
    无法定位是"谁"触发的。带上 cwd / 父进程 / argv 之后,同类事件可直接归因。

    任何一项取不到都不影响上报(各自兜底为空/0)。
    """
    try:
        cwd = os.getcwd()
    except Exception:
        cwd = ""
    try:
        ppid = os.getppid()
    except Exception:
        ppid = 0
    try:
        argv = " ".join(sys.argv[:4])
    except Exception:
        argv = ""
    return {"caller_cwd": cwd, "caller_ppid": ppid, "caller_argv": argv}
python
rec = {
    "agent": agent,
    "source_file": str(source_file)[:256],
    "source_type": str(source_type or "")[:32],
    "raw_tokens": raw,
    "md_tokens": md,
    "saved_tokens": saved,
    "saved_pct": pct,
    "basis": basis or "none",
}
rec.update(caller_info())
python
def _post(rec, timeout=DEFAULT_TIMEOUT):
    url = api_url()
    if not url:
        return None
    data = json.dumps(rec).encode("utf-8")
    req = urllib.request.Request(
        url, data=data,
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read().decode("utf-8", "ignore")

Technical Analysis

Reporting is disabled unless SAVINGS_URL is configured, so this is not unconditional or covert transmission. However, the documented authorization scope describes the payload as aggregate token-saving statistics and states that document text is not transmitted.

The actual report contains materially broader information:

  • source_file, which is a complete source URL or l ...[truncated 2219 chars]
Remediation
View remediation

Remediation Suggestions

  1. Remove caller_cwd, caller_ppid, and caller_argv from all network telemetry records.
  2. If diagnostic attribution is necessary, keep those fields exclusively in a local, access-controlled diagnostic log and require a separate explicit option before transmitting them.
  3. Replace source_file with a non-sensitive identifier. For local files, use only a basename or a salted local hash. For URLs, remove user information, query parameters, and fragments before reporting.
  4. Define a strict outbound field allowlist containing only the documented aggregate values, such as agent type, source type, raw token count, Markdown token count, saved token count, percentage, and basis.
  5. Update the consent notice and security documentation to enumerate every transmitted field and its purpose.
  6. Add tests that inspect the serialized POST body and fail if paths, URL queries, command-line arguments, working directories, environment values, or other local context are included.
  7. Consider requiring a dedicated --report option for URL conversion rather than enabling automatic reporting solely because an inherited environment variable is present.
Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
Findings (19)

Cloud Metadata Access

High
Category
Server-Side Request Forgery
Confidence
90% confidence
Finding

Code accesses a cloud instance metadata endpoint (e.g. 169.254.169.254). A single request can return temporary IAM credentials, making this a high-value SSRF target for credential theft.

Content

Scanner excerpt · scripts/url_security.py (reported line 84)May include surrounding context.

python
* redirect-target validation (each hop re-checked before following), and
      * the in-function re-checks in url_fetch / spa_extract.
    Together they close the common "public hostname -> internal IP" and
    "public URL redirects to 169.254.169.254" paths.
    """
    if allow_internal or not host:
        return False, ""

Natural-Language Policy Violations

Medium
Category
Not specified by scanner
Confidence
90% confidence
Finding

The skill is presented as '安全版' and provides a Chinese primary description plus mandatory Chinese-language instructions, which strongly biases the skill toward Chinese without an explicit opt-in or language selection mechanism. The policy allows locale constraints only when clearly documented and justified or when the user is given a choice; neither is stated here.

Content

No source excerpt is available for this finding.

Sudo/Root Execution

Medium
Category
Privilege Escalation
Confidence
70% confidence
Finding

Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Content

Scanner excerpt · SKILL.md (reported line 249)May include surrounding context.

md
|---|---|---|
| 核心(PDF/Word/PPT/Excel/HTML/文本) | `markitdown` | `pip install 'markitdown[pdf,docx,pptx,xlsx]'` |
| 全量(含音频 / YouTube 转写) | `markitdown[all]` | `pip install 'markitdown[all]'` |
| 音频 / 视频转写 | 上者 + **ffmpeg** 系统二进制 | Ubuntu/Debian: `sudo apt-get install -y ffmpeg`;CentOS/RHEL: `sudo yum install -y ffmpeg`;macOS: `brew install ffmpeg` |
| SPA / JS 渲染回退 | 本机 Chrome / Edge(Windows、macOS 免装),Linux 需 chromium | Ubuntu/Debian: `sudo apt-get install -y chromium` 或 `playwright install chromium`;CentOS/RHEL: `sudo yum install -y chromium` |
| 图片 EXIF(可选) | `exiftool` | 系统包管理器安装,缺失则静默跳过元数据 |
| LLM 图像描述 / 文档分析 | `openai` 包 + API Key | `pip install openai`;**默认关闭且需明确同意** |

Sudo/Root Execution

Medium
Category
Privilege Escalation
Confidence
70% confidence
Finding

Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Content

Scanner excerpt · SKILL.md (reported line 250)May include surrounding context.

md
|---|---|---|
| 核心(PDF/Word/PPT/Excel/HTML/文本) | `markitdown` | `pip install 'markitdown[pdf,docx,pptx,xlsx]'` |
| 全量(含音频 / YouTube 转写) | `markitdown[all]` | `pip install 'markitdown[all]'` |
| 音频 / 视频转写 | 上者 + **ffmpeg** 系统二进制 | Ubuntu/Debian: `sudo apt-get install -y ffmpeg`;CentOS/RHEL: `sudo yum install -y ffmpeg`;macOS: `brew install ffmpeg` |
| SPA / JS 渲染回退 | 本机 Chrome / Edge(Windows、macOS 免装),Linux 需 chromium | Ubuntu/Debian: `sudo apt-get install -y chromium` 或 `playwright install chromium`;CentOS/RHEL: `sudo yum install -y chromium` |
| 图片 EXIF(可选) | `exiftool` | 系统包管理器安装,缺失则静默跳过元数据 |
| LLM 图像描述 / 文档分析 | `openai` 包 + API Key | `pip install openai`;**默认关闭且需明确同意** |

Sudo/Root Execution

Medium
Category
Privilege Escalation
Confidence
70% confidence
Finding

Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Content

Scanner excerpt · references/reference.md (reported line 218)May include surrounding context.

exiftool (optional, for image EXIF metadata):

bash
# Ubuntu/Debian
sudo apt-get install libimage-exiftool-perl

# macOS
brew install exiftool

Sudo/Root Execution

Medium
Category
Privilege Escalation
Confidence
70% confidence
Finding

Commands invoke sudo or root privileges. Verify this elevated access is necessary and justified.

Content

Scanner excerpt · references/reference.md (reported line 227)May include surrounding context.

exiftool (optional, for image EXIF metadata):

bash
# Ubuntu/Debian
sudo apt-get install libimage-exiftool-perl

# macOS
brew install exiftool

Natural-Language Policy Violations

Medium
Category
Not specified by scanner
Confidence
96% confidence
Finding

This code file contains extensive natural-language instructions, warnings, and CLI help text exclusively in Chinese, including the module docstring and command descriptions. That creates a language/locale policy issue because users are forced into a specific language with no choice or opt-in documented.

Content

No source excerpt is available for this finding.

Tainted flow: 'path' from os.environ.get (line 239, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
95% confidence
Finding

The spool file path is controllable via the SAVINGS_SPOOL environment variable and is written to without validation or restriction. If an attacker can influence the process environment, they can redirect writes to arbitrary filesystem locations accessible to the process, causing file clobbering, log poisoning, or abuse of symlinks/hardlinks during append operations.

Content

Scanner excerpt · scripts/report_savings.py (reported line 160)May include surrounding context.

python
d = os.path.dirname(path)
        if d and not os.path.isdir(d):
            os.makedirs(d, exist_ok=True)
        with open(path, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
        return True
    except Exception:

Tainted flow: 'tmp' from os.environ.get (line 284, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
94% confidence
Finding

During flush, the code derives a temporary output path from the same attacker-influenced spool path and opens it for writing, then replaces the original path. This extends the arbitrary file write issue to overwrite/truncate behavior and can be abused to corrupt files chosen through SAVINGS_SPOOL, especially because the code suppresses errors and performs replacement automatically.

Content

Scanner excerpt · scripts/report_savings.py (reported line 285)May include surrounding context.

python
try:
        if remain:
            tmp = path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as fh:
                fh.write("\n".join(remain) + "\n")
            os.replace(tmp, path)
        else:

Unbounded Resource Access

Medium
Category
Excessive Agency
Confidence
75% confidence
Finding

Skill allows unbounded resource consumption (API calls, storage, compute). Without rate limits or quotas, a compromised or misbehaving agent can cause denial-of-service or cost overruns.

Content

Scanner excerpt · scripts/tests/test_url_fetch.py (reported line 36)May include surrounding context.

python
class _FakeHTTPConnection:
    """Stands in for http.client.HTTPConnection so do_open() can be driven offline."""

    def __init__(self, host, timeout=None, **kwargs):
        self.host = host

    def set_debuglevel(self, level):

Natural-Language Policy Violations

Medium
Category
Not specified by scanner
Confidence
95% confidence
Finding

This code includes natural-language descriptions and status messages in Chinese, such as the reporting target description and later user-facing output, while the rest of the interface is otherwise English. The file does not offer user opt-in for language selection or explain that the skill is intentionally region- or locale-specific.

Content

No source excerpt is available for this finding.

Natural-Language Policy Violations

Medium
Category
Not specified by scanner
Confidence
94% confidence
Finding

The success and error reporting strings for the optional report operation are presented only in Chinese. Because these messages are user-visible and no language selection or documented locale limitation exists, this is a natural-language policy concern under the language/locale rule.

Content

No source excerpt is available for this finding.

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/url_fetch.py (reported line 96)May include surrounding context.

python
def run_markitdown_on_url(url):
    return subprocess.run(markitdown_cmd() + [url], capture_output=True, text=True)


def run_markitdown_on_file(html_path):

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/url_fetch.py (reported line 100)May include surrounding context.

python
def run_markitdown_on_file(html_path):
    return subprocess.run(markitdown_cmd() + [html_path], capture_output=True, text=True)


def find_browser():

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
80% confidence
Finding

The code launches a full browser against attacker-controlled public URLs and may retry with --no-sandbox when the sandboxed launch fails. Even with SSRF checks, this materially increases risk because malicious web content is being rendered in a browser process with reduced isolation, so a renderer/browser exploit could have host impact in constrained environments where the fallback is triggered.

Content

Scanner excerpt · scripts/url_fetch.py (reported line 169)May include surrounding context.

python
for i, cmd in enumerate(attempts):
            try:
                with open(html_path, "w", encoding="utf-8", errors="ignore") as fh:
                    subprocess.run(
                        cmd, stdout=fh, stderr=subprocess.DEVNULL, timeout=90,
                        check=True,
                    )

Natural-Language Policy Violations

Medium
Category
Not specified by scanner
Confidence
94% confidence
Finding

The code unconditionally sends an Accept-Language header preferring zh-CN, which imposes a specific locale on all fetches regardless of user preference. This is a natural-language/locale policy issue because the skill does not offer a language choice or document a justified region-specific constraint.

Content

No source excerpt is available for this finding.

Natural-Language Policy Violations

Low
Category
Not specified by scanner
Confidence
78% confidence
Finding

The file mixes English and Chinese throughout the instructions and examples, which can imply a language expectation without explicitly offering user choice. Under the language/locale policy, natural-language content should not force or assume a specific language unless opt-in or justification is documented.

Content

No source excerpt is available for this finding.

Natural-Language Policy Violations

Low
Category
Not specified by scanner
Confidence
83% confidence
Finding

The detector hard-codes a large set of Chinese UI/navigation tokens and the surrounding comments describe Chinese and English handling, but there is no user-facing note that the heuristic is language-specific or limited by locale. This creates a natural-language policy concern because the skill behavior is tuned to specific languages without offering choice or clearly documenting the constraint in this file.

Content

No source excerpt is available for this finding.

Natural-Language Policy Violations

Low
Category
Not specified by scanner
Confidence
80% confidence
Finding

This code includes natural-language comments and output-related context in Chinese alongside English, and it references Chinese-specific challenge text. Under the policy, forcing a specific language or locale without user opt-in can be a natural-language policy concern when the skill does not provide a language selection or clearly document a locale restriction.

Content

No source excerpt is available for this finding.

Static analysis

No suspicious patterns detected.