|
18 | 18 |
|
19 | 19 | import base64 |
20 | 20 | import json |
21 | | -import os |
22 | 21 | import re |
23 | 22 | import sys |
24 | | -import unicodedata |
25 | 23 | from pathlib import Path |
26 | 24 | from typing import Any |
27 | 25 |
|
|
55 | 53 | ("Zero-width characters", "Zero-width space, joiner, or non-joiner detected"), |
56 | 54 | ("Right-to-left override", "RTL override character can hide text direction"), |
57 | 55 | ("Homoglyph characters", "Characters visually similar to ASCII but from different Unicode blocks"), |
| 56 | + ("Unicode Tag characters", "Tags block (U+E0000-E007F) can encode invisible ASCII text readable by LLMs"), |
58 | 57 | ] |
59 | 58 |
|
60 | 59 | SECRET_PATTERNS: list[tuple[str, str, str]] = [ |
@@ -238,6 +237,23 @@ def check_obfuscation(content: str, filepath: str) -> list[dict[str, Any]]: |
238 | 237 | "category": "Obfuscation", |
239 | 238 | }) |
240 | 239 |
|
| 240 | + # Unicode Tag characters (U+E0000 block) — invisible text readable by LLMs |
| 241 | + tag_pattern = re.compile(r"[\U000e0001-\U000e007f]") |
| 242 | + tag_chars = tag_pattern.findall(content) |
| 243 | + if tag_chars: |
| 244 | + # Decode the hidden text |
| 245 | + decoded = "".join( |
| 246 | + chr(ord(c) - 0xE0000) for c in tag_chars if 0xE0020 <= ord(c) <= 0xE007E |
| 247 | + ) |
| 248 | + findings.append({ |
| 249 | + "type": "Unicode Tag Smuggling", |
| 250 | + "severity": "critical", |
| 251 | + "location": filepath, |
| 252 | + "description": f"Invisible Unicode Tag characters detected ({len(tag_chars)} chars). " |
| 253 | + f"Decoded hidden text: {decoded[:200]}", |
| 254 | + "category": "Obfuscation", |
| 255 | + }) |
| 256 | + |
241 | 257 | # Suspicious base64 strings (long base64 that decodes to text with suspicious keywords) |
242 | 258 | b64_pattern = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}") |
243 | 259 | for line_num, line in enumerate(lines, 1): |
@@ -361,9 +377,151 @@ def extract_urls(content: str, filepath: str) -> list[dict[str, Any]]: |
361 | 377 | return urls |
362 | 378 |
|
363 | 379 |
|
| 380 | +def check_structural_attacks(skill_dir: Path, content: str, frontmatter: dict[str, Any] | None) -> list[dict[str, Any]]: |
| 381 | + """Detect structural attack patterns that go beyond text content.""" |
| 382 | + findings: list[dict[str, Any]] = [] |
| 383 | + |
| 384 | + # 1. Symlinks — files that resolve to paths outside the skill directory |
| 385 | + for path in skill_dir.rglob("*"): |
| 386 | + if path.is_symlink(): |
| 387 | + target = path.resolve() |
| 388 | + is_internal = target.is_relative_to(skill_dir.resolve()) |
| 389 | + findings.append({ |
| 390 | + "type": "Symlink Detected", |
| 391 | + "severity": "medium" if is_internal else "critical", |
| 392 | + "location": str(path.relative_to(skill_dir)), |
| 393 | + "description": f"Symlink points to {path.readlink()} (resolves to {str(target)}). " |
| 394 | + "Symlinks can trick agents into reading sensitive files (e.g., ~/.ssh/id_rsa) " |
| 395 | + "disguised as example/reference files.", |
| 396 | + "category": "Symlink Exfiltration", |
| 397 | + }) |
| 398 | + |
| 399 | + # 2. YAML hook exploitation — hooks in frontmatter execute shell commands |
| 400 | + if frontmatter and "hooks" in frontmatter: |
| 401 | + hooks = frontmatter["hooks"] |
| 402 | + hook_types = hooks.keys() if isinstance(hooks, dict) else [] |
| 403 | + for hook_type in hook_types: |
| 404 | + findings.append({ |
| 405 | + "type": "Frontmatter Hooks", |
| 406 | + "severity": "critical", |
| 407 | + "location": "SKILL.md frontmatter", |
| 408 | + "description": f"Skill defines '{hook_type}' hooks. Hooks execute shell commands " |
| 409 | + "automatically on lifecycle events — the model cannot prevent execution. " |
| 410 | + "Review all hook commands carefully.", |
| 411 | + "category": "Hook Exploitation", |
| 412 | + }) |
| 413 | + |
| 414 | + # 3. !`command` pre-prompt injection — runs at template expansion time |
| 415 | + bang_pattern = re.compile(r"!\`[^`]+\`") |
| 416 | + for line_num, line in enumerate(content.split("\n"), 1): |
| 417 | + for match in bang_pattern.finditer(line): |
| 418 | + cmd = match.group()[2:-1] # Strip !` and ` |
| 419 | + findings.append({ |
| 420 | + "type": "Pre-prompt Command", |
| 421 | + "severity": "high", |
| 422 | + "location": f"SKILL.md:{line_num}", |
| 423 | + "description": f"!`command` syntax executes at skill load time before the model sees " |
| 424 | + f"the prompt. Command: {cmd}", |
| 425 | + "evidence": line.strip()[:200], |
| 426 | + "category": "Pre-prompt Injection", |
| 427 | + }) |
| 428 | + |
| 429 | + # 4. Test file auto-discovery — conftest.py, test_*.py, *.test.js/ts |
| 430 | + test_patterns = { |
| 431 | + "conftest.py": "pytest auto-imports conftest.py at collection time — code runs before any tests", |
| 432 | + "test_*.py": "pytest discovers and runs test_*.py files automatically", |
| 433 | + "*_test.py": "pytest discovers and runs *_test.py files automatically", |
| 434 | + "*.test.js": "Jest/Vitest may discover .test.js files if dot:true glob is set", |
| 435 | + "*.test.ts": "Jest/Vitest may discover .test.ts files if dot:true glob is set", |
| 436 | + } |
| 437 | + for path in skill_dir.rglob("*"): |
| 438 | + if not path.is_file(): |
| 439 | + continue |
| 440 | + name = path.name |
| 441 | + for pattern, desc in test_patterns.items(): |
| 442 | + import fnmatch |
| 443 | + if fnmatch.fnmatch(name, pattern): |
| 444 | + findings.append({ |
| 445 | + "type": "Test File Auto-Discovery", |
| 446 | + "severity": "high", |
| 447 | + "location": str(path.relative_to(skill_dir)), |
| 448 | + "description": f"{desc}. Bundled test files execute as a side effect of running " |
| 449 | + "the test suite — review file contents for hidden payloads.", |
| 450 | + "category": "Test File RCE", |
| 451 | + }) |
| 452 | + |
| 453 | + # 5. npm postinstall — bundled package.json with lifecycle scripts |
| 454 | + for pkg_json in skill_dir.rglob("package.json"): |
| 455 | + try: |
| 456 | + pkg = json.loads(pkg_json.read_text(encoding="utf-8", errors="replace")) |
| 457 | + except (json.JSONDecodeError, OSError, ValueError): |
| 458 | + continue |
| 459 | + scripts = pkg.get("scripts") or {} |
| 460 | + lifecycle_hooks = ["preinstall", "install", "postinstall", "preuninstall", "postuninstall"] |
| 461 | + for hook in lifecycle_hooks: |
| 462 | + if hook in scripts: |
| 463 | + findings.append({ |
| 464 | + "type": "npm Lifecycle Hook", |
| 465 | + "severity": "critical", |
| 466 | + "location": str(pkg_json.relative_to(skill_dir)), |
| 467 | + "description": f"package.json defines '{hook}' script: {scripts[hook]}. " |
| 468 | + "npm executes lifecycle hooks automatically on install — " |
| 469 | + "this is a common supply chain attack vector.", |
| 470 | + "category": "Supply Chain", |
| 471 | + }) |
| 472 | + |
| 473 | + # 6. Image metadata — parse PNG chunks properly to find tEXt/iTXt metadata |
| 474 | + import struct |
| 475 | + for img_path in skill_dir.rglob("*.png"): |
| 476 | + try: |
| 477 | + data = img_path.read_bytes() |
| 478 | + # PNG files start with 8-byte signature, then chunks |
| 479 | + # Each chunk: 4-byte length (big-endian), 4-byte type, data, 4-byte CRC |
| 480 | + if data[:8] != b"\x89PNG\r\n\x1a\n": |
| 481 | + continue |
| 482 | + offset = 8 |
| 483 | + while offset + 8 <= len(data): |
| 484 | + chunk_len = struct.unpack(">I", data[offset:offset + 4])[0] |
| 485 | + chunk_type = data[offset + 4:offset + 8] |
| 486 | + chunk_data = data[offset + 8:offset + 8 + chunk_len] |
| 487 | + |
| 488 | + keyword = "" |
| 489 | + value = "" |
| 490 | + if chunk_type == b"tEXt": |
| 491 | + # tEXt: keyword\0text |
| 492 | + parts = chunk_data.split(b"\x00", 1) |
| 493 | + if len(parts) > 1: |
| 494 | + keyword = parts[0].decode("ascii", errors="ignore") |
| 495 | + value = parts[1][:200].decode("latin-1", errors="ignore") |
| 496 | + elif chunk_type == b"iTXt": |
| 497 | + # iTXt: keyword\0comprFlag\0comprMethod\0langTag\0transKeyword\0text |
| 498 | + parts = chunk_data.split(b"\x00", 4) |
| 499 | + if len(parts) >= 5: |
| 500 | + keyword = parts[0].decode("ascii", errors="ignore") |
| 501 | + value = parts[4][:200].decode("utf-8", errors="ignore") |
| 502 | + |
| 503 | + if keyword and value.strip(): |
| 504 | + findings.append({ |
| 505 | + "type": "Image Metadata Text", |
| 506 | + "severity": "high", |
| 507 | + "location": str(img_path.relative_to(skill_dir)), |
| 508 | + "description": f"PNG contains text metadata ('{keyword}'): {value[:100]}. " |
| 509 | + "Hidden instructions in image metadata can be read by " |
| 510 | + "multimodal LLMs when they inspect the file.", |
| 511 | + "category": "Image Injection", |
| 512 | + }) |
| 513 | + |
| 514 | + # Advance to next chunk: length + type(4) + data + CRC(4) |
| 515 | + offset += 4 + 4 + chunk_len + 4 |
| 516 | + except (OSError, struct.error): |
| 517 | + continue |
| 518 | + |
| 519 | + return findings |
| 520 | + |
| 521 | + |
364 | 522 | def compute_description_body_overlap(frontmatter: dict[str, Any] | None, body: str) -> float: |
365 | 523 | """Compute keyword overlap between description and body as a heuristic.""" |
366 | | - if not frontmatter or "description" not in frontmatter: |
| 524 | + if not frontmatter or "description" not in frontmatter or frontmatter["description"] is None: |
367 | 525 | return 0.0 |
368 | 526 |
|
369 | 527 | desc_words = set(re.findall(r"\b[a-z]{4,}\b", frontmatter["description"].lower())) |
@@ -441,7 +599,10 @@ def scan_skill(skill_dir: Path) -> dict[str, Any]: |
441 | 599 |
|
442 | 600 | all_findings.extend(script_findings) |
443 | 601 |
|
444 | | - # 8. Description-body overlap |
| 602 | + # 8. Structural attacks (symlinks, hooks, !command, test files, npm, image metadata) |
| 603 | + all_findings.extend(check_structural_attacks(skill_dir, content, frontmatter)) |
| 604 | + |
| 605 | + # 9. Description-body overlap |
445 | 606 | overlap = compute_description_body_overlap(frontmatter, body) |
446 | 607 |
|
447 | 608 | # Build structure info |
|
0 commit comments