跳到主内容
快讯直播
AI智模界
教程

PHP2Shell 应急:智能体盘点、补丁与 WAF 兜底

这篇能做出什么

假设你收到一条应急通知:某类 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 版本?这两类问题才是下次事件里最先倒下的地方,现在就该排进技术债清单。

最后一句提醒:编码智能体在这次流程里扮演的是"放大你判断力的执行器",它提速的是扫描、比对、批量改动这些机械环节。判定"这个参数能不能被外部控制"这一步,永远要人来做。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。