这篇能做出什么
假设你收到一条应急通知:某类 PHP 应用里长期未升级的老漏洞被批量利用,攻击者能从 HTTP 请求一路打到系统命令执行。这类事件的特点是面广——不是一台机器、一个仓库,而是几十上百个业务仓库里散落着同样的调用点。
照着这篇走完,你会拿到四样东西:
1. 一张 TSV/CSV 资产表:哪个仓库、用的哪个组件、锁在哪个版本、PHP 解释器版本是多少。
2. 一份调用点清单:每个可疑的 sink(危险函数调用)在哪个文件第几行,从入口到 sink 的调用链是什么,置信度如何。
3. 一批可复现的补丁分支:每个仓库一个 hotfix/* 分支,composer.lock 有 diff,能直接开 PR。
4. 一层临时 WAF 兜底 + 一套回归脚本:先把攻击面挡住,再用脚本证明补丁没打坏业务。
顺序很重要:先挡(WAF 观察模式)→ 再盘(资产 + 调用点)→ 再修(补丁)→ 再验(回归)→ 最后撤临时规则。反过来做,等于在裸奔期间排队打补丁。
前置条件清单
动手之前确认这些条件,缺哪项先补:
- [ ] 一份机器/域名清单,以及域名到代码仓库的映射关系(哪怕是张 Excel)
- [ ] 所有相关仓库的读权限,以及能推分支的写权限
- [ ] 本地或 CI 环境有
php、composer、git、rg(ripgrep)、curl - [ ] 一个能跑通现有测试的 staging 环境(回归验证必须有靶子)
- [ ] 一个终端形态的编码智能体:能在仓库目录里读文件、写文件、执行命令。各家能力差异较大,以官方文档当前版本为准,选能开"只读模式 / 需确认再执行命令"的那个
- [ ] Nginx 或云 WAF 的配置权限(能改
map、能加自定义规则) - [ ] 一份书面应急单:漏洞编号、影响组件、判定标准、回滚方式,具体编号与受影响版本以厂商公告和官方安全公告为准
一条纪律:智能体在盘点阶段一律只读。让它改代码,必须在你已经确认调用点清单之后。
---
步骤一:先止血,上 WAF 观察模式
不要等补丁。先在最外层加一条针对性的临时规则。
Nginx 场景,在 http 块加一个 map,把疑似 PHP 序列化 payload 的请求标记出来:
```nginx
/etc/nginx/conf.d/emergency-php2shell.conf
map $args $php_serialized_hit {
default 0;
"~*O%3A[0-9]+%3A" 1;
"~*O:[0-9]+:" 1;
}
```
然后在对应的 server 块里先只记日志不拦:
```nginx
server {
listen 443 ssl;
server_name app.example.com;
观察期:只打标,返回 403 的动作先注释掉
if ($php_serialized_hit) { return 403; }
access_log /var/log/nginx/emergency.log combined;
}
```
跑一段时间后,统计命中量:
```bash
grep -c 'php_serialized_hit' /var/log/nginx/emergency.log || true
awk '{print $7}' /var/log/nginx/emergency.log | sort | uniq -c | sort -rn | head -n 20
```
如果命中的全是你自己的压测流量和几个老接口,再把 return 403; 打开。
ModSecurity(CRS)场景,写一个独立规则文件,规则 id 要避开 CRS 已占用的区间,从 9000000 往后的自定义段取:
```apache
/etc/modsecurity.d/emergency-php2shell.conf
SecRuleEngine DetectionOnly
SecRule ARGS_NAMES|ARGS "@rx O:[0-9]+:\"" \
"id:9001001,phase:2,pass,log,\
msg:'PHP serialized object payload',\
tag:'emergency/php2shell',severity:2"
```
DetectionOnly 就是观察模式。确认误报可控后,把 DetectionOnly 改成 On,把 pass 改成 deny,status:403,重载服务。
两个必须补的动作:
- 源站别裸奔。如果 CDN 回源地址可直接访问,WAF 规则等于不存在。给源站加 ACL,只放行 WAF 回源段。
- 规则只挡已知特征。它挡不住变形 payload,所以它只是拖延战术,不能替代补丁。
步骤二:资产盘点,先摸清家底
在代码根目录跑这个脚本,把所有 composer.lock 里的组件与版本摊平成一张表:
```bash
#!/usr/bin/env bash
inventory.sh 用法: ./inventory.sh /srv/repos out/php-assets.tsv
set -euo pipefail
ROOT="${1:-.}"
OUT="${2:-php-assets.tsv}"
mkdir -p "$(dirname "$OUT")"
printf 'repo\tlockfile\tpackage\tversion\n' > "$OUT"
find "$ROOT" -name composer.lock -not -path '*/vendor/*' -print0 \
| while IFS= read -r -d '' lock; do
repo=$(dirname "$lock")
php -r '
$lock = json_decode(file_get_contents($argv[1]), true);
if (!is_array($lock)) { exit(0); }
$pkgs = array_merge($lock["packages"] ?? [], $lock["packages-dev"] ?? []);
foreach ($pkgs as $p) {
printf("%s\t%s\t%s\t%s\n", $argv[2], $argv[1], $p["name"], $p["version"]);
}
' "$lock" "$repo" >> "$OUT"
done
echo "wrote $(wc -l < "$OUT") lines to $OUT"
```
筛出受影响组件:
```bash
awk -F'\t' 'NR==1 || $3=="vendor/package-name"' php-assets.tsv
```
注意 vendor/package-name 换成你应急单里实际涉及的组件名。
第二步,核对线上跑的版本和仓库里的锁文件是否一致。很多事故就出在这个错位上:
```bash
在目标主机上执行
php -v
composer show --locked --no-dev 2>/dev/null | head -n 20
```
对不上的仓库单独标出来,走人工确认,别指望脚本自动判定。
步骤三:让编码智能体找调用点
这一步是效率的关键。手动 grep 会淹死在误报里,因为 unserialize() 在业务代码里可能到处都是,但真正能被外部输入触达的只有少数。
先自己在终端做一轮粗筛,把范围压小:
```bash
rg -n --type php \
-e 'unserialize\s*\(' \
-e 'call_user_func' \
-e 'file_get_contents\s*\(\s*\$_' \
-g '!vendor/' -g '!tests/' \
> rough-scan.txt
wc -l rough-scan.txt
```
然后把精筛交给智能体。给它一张任务卡,写得越死,它跑偏越少:
```text
你在 /srv/repos 目录下工作。这是一次只读排查任务。
【禁止事项】
- 不修改任何文件,不执行 git add / commit / checkout
- 不安装依赖,不运行 composer install / update
- 不访问外网
【目标】
在工作区内所有 PHP 仓库中,找出「外部可控输入最终到达 sink」的调用点。
sink 定义:unserialize / call_user_func(_array) / include 变量路径 / eval / system 家族。
入口定义:$_GET / $_POST / $_REQUEST / php://input / 路由参数 / 上传文件名。
【判定要求】
1. 每条结果必须给出 文件:行号、sink 名称、从入口到 sink 的函数名调用链
2. 中间经过 3 层以上函数跳转、无法确认参数来源的,标 confidence: low
3. 不确定就标 low,不要猜,不要为了凑数编造调用链
4. 忽略 vendor/ 和 tests/ 目录
【输出】
写入 /tmp/callsites.tsv,制表符分隔,表头:
repo file line sink chain confidence
完成后执行 wc -l /tmp/callsites.tsv 并报告行数。
```
跑完先看 confidence: high 的部分,逐条人工复核:
```bash
awk -F'\t' '$6=="high"' /tmp/callsites.tsv | head -n 40
```
复核只问一个问题:这个参数,攻击者能不能控制? 能,就进补丁队列;不能,标注理由归档。
步骤四:批量打补丁
优先级从高到低:
1. 升级依赖(最优,改配置不改代码)
2. 改调用方式(绕开危险 API,比如 unserialize 换 json_decode)
3. 入口加白名单校验(最次,是缓解不是修复)
升级依赖的批量脚本,每个仓库一个分支:
```bash
#!/usr/bin/env bash
patch-one.sh 用法: ./patch-one.sh /srv/repos/app-a vendor/package-name
set -euo pipefail
REPO="$1"; PKG="$2"
BRANCH="hotfix/$(echo "$PKG" | tr '/' '-')"
cd "$REPO"
git status --porcelain | grep -q . && { echo "工作区不干净,跳过 $REPO"; exit 1; }
git switch -c "$BRANCH" 2>/dev/null || git switch "$BRANCH"
composer update --with-all-dependencies --no-interaction "$PKG"
composer validate --no-check-publish
php -l $(git diff --name-only --diff-filter=ACM | grep '\.php$' || true) 2>/dev/null || true
git add composer.json composer.lock
git commit -m "chore(security): bump ${PKG} for emergency fix"
git push -u origin "$BRANCH"
```
如果 composer.json 里的版本约束卡住了升级,脚本会失败——这是好事,说明需要人工先放宽约束再重跑,不要用 --ignore-platform-reqs 硬来。
批量循环:
```bash
while IFS=$'\t' read -r repo _ pkg _; do
[ "$repo" = "repo" ] && continue
./patch-one.sh "$repo" "$pkg" || echo "FAILED: $repo" >> patch-failed.log
done < php-assets.tsv
```
改代码而非升级的场景,让智能体按已有 high 置信度清单动手,任务卡要加两条硬约束:
```text
【改动边界】
- 只允许修改调用点清单里列出的文件
- 每个改动必须保持原有函数签名不变
- 改完对每个文件执行 php -l,语法错误立即回滚该文件
- 输出 diff 摘要,说明每处改动为什么能阻断该调用链
```
改完自己过一遍 diff:
```bash
git diff --stat
git diff -U3 | head -n 120
```
步骤五:回归验证
补丁没有回归验证等于没打。写一个探针脚本,覆盖三件事:站点活着、核心链路通、恶意 payload 被正确拒绝。
```bash
#!/usr/bin/env bash
verify.sh 用法: ./verify.sh https://staging.example.com
set -uo pipefail
BASE="${1:?usage: verify.sh <base-url>}"
PASS=0; FAIL=0
check() {
local name="$1" path="$2" want="$3"
local code
code=$(curl -sS -m 10 -o /tmp/verify.out -w '%{http_code}' "${BASE}${path}" || echo "000")
if [ "$code" = "$want" ]; then
printf 'PASS\t%s\t%s\t%s\n' "$name" "$path" "$code"; PASS=$((PASS+1))
else
printf 'FAIL\t%s\t%s\tgot=%s want=%s\n' "$name" "$path" "$code" "$want"; FAIL=$((FAIL+1))
fi
}
check "health" "/healthz" "200"
check "home" "/" "200"
check "login-page" "/login" "200"
check "search" "/search?q=test" "200"
check "probe-sqli" "/search?q=%27%20OR%201%3D1--" "200"
printf '\nsummary: pass=%d fail=%d\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
```
再补一个"恶意 payload 不再触发命令执行"的验证——用一个无副作用的探针命令(比如 id 或 echo),确认响应里不出现命令输出特征:
```bash
curl -sS "${BASE}/api/probe?data=O%3A8%3A%22stdClass%22%3A0%3A%7B%7D" \
| grep -E 'uid=|gid=' && echo "仍然可被利用" || echo "payload 未生效"
```
如果后端有单元测试,直接跑:
```bash
composer install --no-interaction
vendor/bin/phpunit --testsuite unit 2>&1 | tail -n 30
```
全部通过后,把 verify.sh 的输出存档,作为应急闭环的证据。
常见坑与排错
智能体改了不该改的文件。 症状:diff 里出现一堆格式化改动、无关文件被重写。原因通常是任务卡没写改动边界。对策:只读阶段和改动阶段分开跑;改动阶段指定文件白名单;每批改完立刻 git diff --stat 核对。
锁文件和线上不一致。 症状:仓库显示已升级,线上仍然被打。对策:把"线上 composer show --locked 输出"作为资产表的必填列,对不上的单独列白名单,手工确认部署链路。
PHP 解释器用错。 症状:composer update 报平台依赖不满足,或者 php -l 通过但测试跑挂。对策:所有脚本显式绑定解释器版本,或者用容器跑;别再依赖 shell 里默认的 php。
grep 扫出几千条误报。 症状:rough-scan.txt 上万行,无法处理。对策:先按 -g '!vendor/' 和 -g '!tests/' 缩小,再按目录分组分批交给智能体;每次只喂一个仓库,比一次喂全部效果好。
WAF 规则误伤正常业务。 症状:某些表单提交 403。对策:观察模式至少跑满一个业务高峰;把命中的 URI 和参数名打印出来逐条核对;必要时把规则收窄到具体 URI 前缀,而不是全站。
规则写在 CDN 上,源站仍可直连。 症状:CDN 日志里没攻击,源站日志里全是。对策:源站加安全组白名单,只放行 WAF 回源地址段;上线后用外部视角扫一遍源站端口是否暴露。
多个 hotfix 分支互相冲突。 症状:第二个仓库合并时 composer.lock 大面积冲突。对策:一个仓库一个分支,不要跨仓库共用;composer.lock 冲突一律以重新执行 composer update 的结果为准,不要手工解冲突。
临时规则忘了撤。 症状:半年后业务调整参数格式,被一条没人记得的规则挡住。对策:应急单里写死"临时规则到期日 + 责任人",规则文件头部加注释标注来源工单号。
下一步建议
应急结束后别急着收工,把这次的临时动作沉淀成常态机制:
- 把资产盘点脚本接进 CI,每次合并都刷新一遍组件清单,形成轻量 SBOM;配合
composer audit,让"用了什么版本"变成随时可查的事实而不是一次性的考古。 - 把调用点扫描规则固化下来。这次靠智能体找的 sink 模式,写成
semgrep规则或自定义 lint 规则,进 pre-commit hook。规则不用一步到位,先把高频的几条拦住。 - WAF 规则转正。把应急期验证有效的规则,整理成带 id、带注释、带测试用例的正式规则集,纳入变更评审,而不是留在某台机器的
conf.d里。 - 补丁流程脚本化。
patch-one.sh加个参数就能复用到下一次组件升级,把"应急"变成"例行"。 - 做一次复盘:哪些仓库根本没有测试所以无法回归验证?哪些服务还在用已经停止维护的 PHP 版本?这两类问题才是下次事件里最先倒下的地方,现在就该排进技术债清单。
最后一句提醒:编码智能体在这次流程里扮演的是"放大你判断力的执行器",它提速的是扫描、比对、批量改动这些机械环节。判定"这个参数能不能被外部控制"这一步,永远要人来做。
