扩展地址
https://github.com/HOSTED-POWER/php_uploadprogress_guard
编译扩展
git clone https://github.com/HOSTED-POWER/php_uploadprogress_guard.git
cd php_uploadprogress_guard/src && phpize && ./configure --with-php-config=/你的PHP安装路径/bin/php-config && make && make install
extension=hp_uploadprogress_guard.so
service php-fpm reload
测试验证
#!/usr/bin/env bash
# verify_guard.sh — 验证 hp_uploadprogress_guard 是否真正生效
#
# 用法:
# ./verify_guard.sh # 核心检查 + 本地隔离 OOM 测试(不碰生产)
# WEBROOT=/www/htdocs/guardtest \
# PROBE_URL=http://127.0.0.1/guardtest/probe.php ./verify_guard.sh
# WITH_OOM=0 ./verify_guard.sh # 跳过 OOM 测试
# PHP=/www/server/php/56/bin/php PHPFPM=/www/server/php/56/sbin/php-fpm ./verify_guard.sh
#
set -uo pipefail
PHP=${PHP:-php}
PHPFPM=${PHPFPM:-php-fpm}
EXT=hp_uploadprogress_guard
PROBE_URL=http://127.0.0.1/probe.php # web_root指向:/www/htdocs/guardtest
WEBROOT=/data/www/wwwroot/guardtest/
WITH_OOM=${WITH_OOM:-1}
PORT=${PORT:-18081}
PASS=0; FAIL=0; SKIP=0
ok() { printf ' [PASS] %s\n' "$*"; PASS=$((PASS+1)); }
bad() { printf ' [FAIL] %s\n' "$*"; FAIL=$((FAIL+1)); }
skip() { printf ' [SKIP] %s\n' "$*"; SKIP=$((SKIP+1)); }
info() { printf ' %s\n' "$*"; }
cleanup() {
[ -n "${SRV1:-}" ] && kill "$SRV1" 2>/dev/null
[ -n "${SRV2:-}" ] && kill "$SRV2" 2>/dev/null
[ -n "${JAR:-}" ] && rm -f "$JAR"
[ -n "${TMPF:-}" ] && rm -f "$TMPF"
[ -n "${TMPD:-}" ] && rm -rf "$TMPD"
}
trap cleanup EXIT
echo "============================================================"
echo " hp_uploadprogress_guard 验证 $(date '+%F %T')"
echo "============================================================"
# ---------------------------------------------------------------- 1. 基础
echo
echo "[1] 基础信息"
command -v "$PHP" >/dev/null 2>&1 || { bad "找不到 PHP: $PHP"; exit 2; }
"$PHP" -v | head -1
info "SAPI: $("$PHP" -r 'echo PHP_SAPI;')"
info "PHP 路径: $(command -v "$PHP")"
# ---------------------------------------------------------------- 2. 模块
echo
echo "[2] 扩展加载"
if "$PHP" -m 2>/dev/null | grep -qx "$EXT"; then
ok "CLI 已加载 $EXT"
else
bad "CLI 未加载 $EXT —— 先检查 extension_dir / scan dir / ini"
fi
RI=$("$PHP" --ri "$EXT" 2>/dev/null || true)
if [ -z "$RI" ]; then
bad "php --ri $EXT 无输出"
else
echo "$RI" | sed 's/^/ /'
if echo "$RI" | grep -qiE 'detached.*(yes|true|on)'; then
ok "upload-progress 回调已摘除(detached)"
else
bad "没有看到 detached => yes,回调可能仍挂着"
fi
echo "$RI" | grep -qiE 'engine carries the official fix.*(yes|true)' \
&& info "扩展自述:引擎已带官方修复,guard 让位"
fi
# 引擎是否自带官方修复符号(有则说明 guard 会主动让位)
BIN=$("$PHP" -n -r 'echo PHP_BINARY;')
SYM=""
if command -v nm >/dev/null 2>&1; then
SYM=$(nm -D "$BIN" 2>/dev/null | grep -E 'zend_mm_safe_error_ex|zend_handle_delayed_oom' || true)
fi
if [ -z "$SYM" ] && command -v strings >/dev/null 2>&1; then
SYM=$(strings "$BIN" 2>/dev/null | grep -E '^(zend_mm_safe_error_ex|zend_handle_delayed_oom)$' || true)
fi
if [ -n "$SYM" ]; then
ENGINE_PATCHED=1
info "引擎含官方修复符号:$(echo "$SYM" | head -1)"
info "→ 该 PHP 本身已修复,guard 只是自动让位"
else
ENGINE_PATCHED=0
info "引擎不含官方修复符号 → 依赖 guard 保护"
fi
UPINI=$("$PHP" -r 'echo ini_get("session.upload_progress.enabled");')
if [ "$UPINI" = "0" ]; then
ok "session.upload_progress.enabled=0"
else
info "session.upload_progress.enabled=$UPINI(引擎已修复时允许为 1)"
fi
# ---------------------------------------------------------------- 3. FPM
echo
echo "[3] 生产 SAPI(FPM)检查"
if command -v "$PHPFPM" >/dev/null 2>&1; then
if "$PHPFPM" -m 2>/dev/null | grep -qx "$EXT"; then
ok "FPM 已加载 $EXT"
else
bad "FPM 未加载 $EXT(CLI 加载了不代表 FPM 加载了!)"
fi
if "$PHPFPM" -i 2>/dev/null | grep -qiE 'detached.*(yes|true)'; then
ok "FPM 上下文中回调已摘除"
fi
FOUND=0
for p in $(pgrep -x php-fpm 2>/dev/null || true); do
if grep -q "$EXT" "/proc/$p/maps" 2>/dev/null; then FOUND=$((FOUND+1)); fi
done
if [ "$FOUND" -gt 0 ]; then
ok "有 $FOUND 个 php-fpm 进程映射了 $EXT"
else
bad "没有 php-fpm 进程映射 $EXT(可能只重启了 CLI)"
fi
else
skip "找不到 $PHPFPM,跳过 FPM 检查"
fi
# ---------------------------------------------------------------- 4. 行为:session
echo
echo "[4] 行为验证:session.upload_progress 是否还会写入"
if [ -n "$WEBROOT" ]; then
mkdir -p "$WEBROOT"
cat > "$WEBROOT/probe.php" <<'PHP'
<?php
header('Content-Type: application/json');
session_start();
$keys = array_keys($_SESSION);
$hit = array_values(preg_grep('/^upload_progress_/', $keys));
echo json_encode([
'php' => PHP_VERSION,
'sapi' => PHP_SAPI,
'guard_loaded' => extension_loaded('hp_uploadprogress_guard'),
'upload_progress_ini' => ini_get('session.upload_progress.enabled'),
'session_keys' => $keys,
'has_progress' => $hit ? true : false,
], JSON_UNESCAPED_SLASHES), "\n";
PHP
printf 'session.upload_progress.cleanup=0\n' > "$WEBROOT/.user.ini"
info "已写入 $WEBROOT/probe.php 和 .user.ini(测完记得删)"
fi
if [ -n "$PROBE_URL" ]; then
JAR=$(mktemp); TMPF=$(mktemp); head -c 10240 /dev/urandom > "$TMPF"
# 请求 A:带 PHP_SESSION_UPLOAD_PROGRESS 的 multipart 上传
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -c "$JAR" \
-F "PHP_SESSION_UPLOAD_PROGRESS=guardprobe" -F "f=@$TMPF" \
"$PROBE_URL?mode=upload" 2>/dev/null || echo 000)
info "上传请求 HTTP $CODE"
# 请求 B:同一 session 读 $_SESSION
RESP=$(curl -sS -b "$JAR" -c "$JAR" "$PROBE_URL?mode=read" 2>/dev/null || true)
[ -n "$RESP" ] && info "响应: $RESP"
if echo "$RESP" | grep -q '"has_progress":true'; then
bad "session 里仍出现 upload_progress_* → 回调没有被摘除"
elif echo "$RESP" | grep -q '"session_keys"'; then
ok "session 中没有 upload_progress_* → 该路径已失效"
else
bad "探测页面返回异常,无法判断"
fi
case "$WEBROOT" in "") ;; *) rm -f "$WEBROOT/probe.php" "$WEBROOT/.user.ini" ;; esac
else
skip "未设置 PROBE_URL,跳过 HTTP 行为验证"
fi
# ---------------------------------------------------------------- 5. 行为:OOM
echo
echo "[5] 行为验证:multipart 内存放大(隔离环境,不碰生产)"
if [ "$WITH_OOM" != "1" ]; then
skip "WITH_OOM=0"
elif [ "${ENGINE_PATCHED:-0}" = "1" ]; then
skip "引擎已自带官方修复,OOM 路径不需要 guard 接管"
elif ! "$PHP" -m 2>/dev/null | grep -qx "$EXT"; then
skip "扩展未加载,无法测试"
else
SO=$("$PHP" -n -r 'echo ini_get("extension_dir");')/$EXT.so
if [ ! -f "$SO" ]; then
bad "找不到 $SO"
else
TMPD=$(mktemp -d)
echo '<?php echo "OK";' > "$TMPD/index.php"
# 构造 40MB 单体字段的 multipart(post_max_size 内,但超出 memory_limit)
BODY=$TMPD/body.bin
"$PHP" -n -r '
$f = fopen($argv[1], "wb");
fwrite($f, "--X\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n");
$chunk = str_repeat("a", 1 << 20);
for ($i = 0; $i < 40; $i++) fwrite($f, $chunk);
fwrite($f, "\r\n--X--\r\n");
' "$BODY"
# 服务端 1:带 guard
"$PHP" -n -d memory_limit=8M -d post_max_size=64M -d extension="$SO" \
-S "127.0.0.1:$PORT" -t "$TMPD" >"$TMPD/srv1.log" 2>&1 &
SRV1=$!; sleep 1
C1=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: multipart/form-data; boundary=X' \
--data-binary @"$BODY" "http://127.0.0.1:$PORT/" 2>/dev/null || echo 000)
info "带 guard: HTTP $C1"
# 服务端 2:不带 guard(对照组,证明这个用例本身能触发问题)
"$PHP" -n -d memory_limit=8M -d post_max_size=64M \
-S "127.0.0.1:$((PORT+1))" -t "$TMPD" >"$TMPD/srv2.log" 2>&1 &
SRV2=$!; sleep 1
C2=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: multipart/form-data; boundary=X' \
--data-binary @"$BODY" "http://127.0.0.1:$((PORT+1))/" 2>/dev/null || echo 000)
info "不带 guard(对照): HTTP $C2"
if [ "$C1" = "413" ] || grep -q 'multipart body dropped' "$TMPD/srv1.log" 2>/dev/null; then
ok "带 guard 的请求被干净拒绝(413 / body dropped)"
else
bad "带 guard 仍返回 $C1,未看到 413 或 body dropped"
fi
if [ "$C2" = "413" ]; then
info "对照组也返回 413,用例不成立(模块被 ini 自动加载?)"
elif [ "$C2" = "200" ]; then
info "对照组未复现 OOM,调大 body 或调低 memory_limit 再试"
else
ok "对照组异常(HTTP $C2)→ 该请求在无 guard 时确实会崩,用例有效"
fi
echo " srv1.log: $(tail -3 "$TMPD/srv1.log" 2>/dev/null | tr '\n' ' ')"
kill "$SRV1" "$SRV2" 2>/dev/null; SRV1=""; SRV2=""
fi
fi
# ---------------------------------------------------------------- 汇总
echo
echo "============================================================"
printf " PASS=%d FAIL=%d SKIP=%d\n" "$PASS" "$FAIL" "$SKIP"
if [ "$FAIL" -eq 0 ]; then
echo " 结论:通过。仍建议在灰度机观察 24h 上传/表单日志。"
else
echo " 结论:有失败项,先别铺量。"
fi
echo "============================================================"
exit $([ "$FAIL" -eq 0 ] && echo 0 || echo 1)