PHP远程代码执行漏洞(CVE-2026-109618/CVE-2026-109619)增加扩展快速修复

扩展地址

https://github.com/HOSTED-POWER/php_uploadprogress_guard  
  • 全面支持了5.4-8.5的版本

编译扩展

  • 编译
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  
  • php.ini增加配置
extension=hp_uploadprogress_guard.so  
  • 重载php-fpm
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)