T05 · Unauthorized Access and Privilege Escalation
- Location
scripts/qa_doc_manager.py:348- Finding
Arbitrary File Read Through the Q&A Document Retrieval Command
- Content
View full analysis
Optional[str]: """Read Q&A document content.""" full_path = os.path.join(project_dir, ".claude", doc_path) if os.path.exists(full_path): try: with open(full_path, 'r', encoding='utf-8') as f: return f.read() except: pass return None ``` The function is directly reachable through the command-line interface: ```python elif command == "get": if len(sys.argv) < 4: print("Usage: qa_doc_manager.py get ") sys.exit(1) content = get_qa_doc_content(project_dir, sys.argv[3]) if content: print(content) else: print(json.dumps({"error": "Document does not exist"})) ``` ### Technical Analysis The `doc_path` argument comes directly from `sys.argv[3]` and is passed to `os.path.join()` without validation or canonical containment checking. An attacker can use either of the following path behaviors: 1. A path containing `../` components can escape the intended `/.claude` directory. 2. On supported platforms, an absolute `doc_path` causes `os.path.join()` to discard the preceding project path components. The code only verifies that the resulting path exists. It does not verify that the canonical path remains within the intended Q&A document directory. It then prints the complete file contents to standard output. ### Attack Path 1. An attacker or untrusted instruction causes the agent to invoke the Q&A `get` command with a crafted path. 2. The attacker supplies an absolute path or a traversal path, for example: ```bash python3 scripts/qa_doc_manager.py /target/project get /etc/passwd ``` or: ```bash python ...[truncated 995 chars]- Remediation
View remediation
Optional[str]: qa_root = ( Path(project_dir).resolve() / ".claude" / "docs" / "qa" ).resolve() supplied = Path(doc_path) if supplied.is_absolute(): return None candidate = (Path(project_dir).resolve() / ".claude" / supplied).resolve() try: candidate.relative_to(qa_root) except ValueError: return None if not candidate.is_file() or candidate.is_symlink(): return None return candidate.read_text(encoding="utf-8") ``` ]]>
