From 01a082c8da0243a82382d476dd13dee53c610e9d Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Sun, 24 May 2026 17:45:35 +0800 Subject: [PATCH 01/19] docs: improve xss module guidance --- .../modules/xss/controller/DomController.java | 2 +- .../xss/controller/OtherController.java | 5 +- .../xss/controller/ReflectController.java | 22 +-- .../xss/controller/StoreController.java | 2 +- src/main/resources/static/js/staticcode.js | 62 +++++-- .../resources/templates/vul/xss/dom-href.html | 6 +- src/main/resources/templates/vul/xss/dom.html | 160 +++++++++++++++++- .../resources/templates/vul/xss/other.html | 22 ++- .../templates/vul/xss/reflect/safe.html | 42 +++-- .../templates/vul/xss/reflect/vul.html | 10 +- .../resources/templates/vul/xss/store.html | 21 ++- 11 files changed, 273 insertions(+), 81 deletions(-) diff --git a/src/main/java/top/whgojp/modules/xss/controller/DomController.java b/src/main/java/top/whgojp/modules/xss/controller/DomController.java index d745015..e5034c8 100644 --- a/src/main/java/top/whgojp/modules/xss/controller/DomController.java +++ b/src/main/java/top/whgojp/modules/xss/controller/DomController.java @@ -14,7 +14,7 @@ * @Date: 2024/5/23 17:25 */ @Slf4j -@Api(value = "ReflectController", tags = "跨站脚本-Dom型XSS") +@Api(value = "DomController", tags = "跨站脚本-DOM型XSS") @Controller @CrossOrigin(origins = "*") @RequestMapping("/xss/dom") diff --git a/src/main/java/top/whgojp/modules/xss/controller/OtherController.java b/src/main/java/top/whgojp/modules/xss/controller/OtherController.java index 727ce23..e79a44b 100644 --- a/src/main/java/top/whgojp/modules/xss/controller/OtherController.java +++ b/src/main/java/top/whgojp/modules/xss/controller/OtherController.java @@ -84,7 +84,7 @@ public R hackCookie(@RequestParam String cookie, HttpServletRequest request) { private UploadUtil uploadUtil; // 文件上传接口 - @ApiOperation(value = "漏洞场景:文件上传导致存储XSS", notes = "原生漏洞场景,未加任何过滤,Controller接口返回Json类型结果") + @ApiOperation(value = "漏洞场景:文件上传导致存储XSS", notes = "上传可被浏览器或预览服务解析的文件,后续访问文件时可能触发XSS") @RequestMapping("/vul1Upload") @ResponseBody @SneakyThrows @@ -110,6 +110,7 @@ public R vul1Upload(@RequestParam("file") MultipartFile file, } catch (Exception e) { return R.error("上传错误,请检查后重新上传:" + e.getMessage()); } + // XML解析成功后继续落盘,便于演示“解析 + 可访问文件”组合场景。 case "html": case "svg": case "pdf": @@ -122,7 +123,7 @@ public R vul1Upload(@RequestParam("file") MultipartFile file, return R.error(res); } } - @ApiOperation(value = "漏洞场景:模版引擎解析导致存储XSS", notes = "") + @ApiOperation(value = "漏洞场景:模板引擎不安全渲染导致XSS", notes = "th:utext会把内容作为HTML渲染,th:text会进行转义") @GetMapping("/vul2OtherTemplate") public String vul2OtherTemplate(@RequestParam("payload") String payload, @RequestParam("type") String type, Model model) { diff --git a/src/main/java/top/whgojp/modules/xss/controller/ReflectController.java b/src/main/java/top/whgojp/modules/xss/controller/ReflectController.java index 7df5caf..6266dd4 100644 --- a/src/main/java/top/whgojp/modules/xss/controller/ReflectController.java +++ b/src/main/java/top/whgojp/modules/xss/controller/ReflectController.java @@ -41,7 +41,7 @@ public String reflect(@PathVariable String view) { return isValidView(view) ? "vul/xss/reflect/" + view : "error/404"; } - @ApiOperation(value = "漏洞场景:GET型与POST型", notes = "原生漏洞场景,未加任何过滤,Controller接口返回Json类型结果") + @ApiOperation(value = "漏洞场景:GET型与POST型", notes = "原生漏洞场景,未加任何过滤,Controller接口返回JSON类型结果。JSON本身通常不会直接触发XSS,但前端不安全渲染JSON字段时可能触发") @RequestMapping("/vul1") @ResponseBody @ApiImplicitParam(name = "payload", value = "请求参数", dataType = "String", paramType = "query", dataTypeClass = String.class) @@ -59,7 +59,7 @@ public String vul2(@ApiParam(name = "payload", value = "请求参数", required } @SneakyThrows - @ApiOperation(value = "漏洞场景:Content-Type问题", notes = "Tomcat内置HttpServletResponse,Content-Type导致反射XSS") + @ApiOperation(value = "漏洞场景:Content-Type问题", notes = "响应Content-Type决定浏览器解析方式,不可信内容以text/html返回时可能导致反射XSS") @GetMapping("/vul3") @ResponseBody @ApiImplicitParams({ @@ -77,7 +77,7 @@ public void vul3(@ApiParam(name = "type", value = "类型", required = true) @Re case "plain": log.info("[+]XSS-反射性-Content-Type:text/plain;charset=utf-8:" + payload); response.getWriter().print(payload); - response.setContentType("text/plain;charset=utf-8"); // response默认返回Content-Type类型是text/plain + response.setContentType("text/plain;charset=utf-8"); response.getWriter().flush(); break; default: @@ -91,7 +91,7 @@ public void vul3(@ApiParam(name = "type", value = "类型", required = true) @Re private static final String WHITELIST_REGEX = "^[a-zA-Z0-9_\\s]+$"; private static final Pattern pattern = Pattern.compile(WHITELIST_REGEX); - @ApiOperation(value = "安全代码:用户输入验证和过滤", notes = "对用户输入的数据进行验证和过滤,确保不包含恶意代码。使用白名单过滤,只允许特定类型的输入,如纯文本或指定格式的数据") + @ApiOperation(value = "安全代码:用户输入验证和过滤", notes = "使用白名单限制输入格式,适合约束字段类型;最终仍需根据输出位置进行上下文编码") @GetMapping("/safe1") @ResponseBody @ApiImplicitParams({ @@ -116,13 +116,13 @@ public R safe1(@ApiParam(name = "type", value = "类型", required = true) @Requ return R.ok(filterContented); } - @ApiOperation(value = "安全代码:内容安全策略-CSP防护", notes = "内容安全策略(Content Security Policy)是一种由浏览器实施的安全机制,旨在减少和防范跨站脚本攻击(XSS)等安全威胁。它通过允许网站管理员定义哪些内容来源是可信任的,从而防止恶意内容的加载和执行") + @ApiOperation(value = "安全代码:内容安全策略-CSP防护", notes = "内容安全策略(Content Security Policy)是由浏览器实施的额外防护层,可降低恶意脚本加载和执行风险,但不能替代输出编码与安全模板/DOM用法") @GetMapping("/safe2") @ResponseBody @ApiImplicitParam(name = "payload", value = "请求参数", dataType = "String", paramType = "query", dataTypeClass = String.class) public String safe2(@ApiParam(name = "payload", value = "请求参数", required = true) @RequestParam String payload, HttpServletResponse response) { - response.setHeader("Content-Security-Policy", "default-src self"); - response.setHeader("Content-Security-Policy-Report-Only", "default-src 'self'; other-uri /xss/reflect/csp-other-endpoint"); + response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'"); + response.setHeader("Content-Security-Policy-Report-Only", "default-src 'self'; report-uri /xss/reflect/csp-report-endpoint"); log.info("[-]XSS-反射性-内容安全策略-CSP防护:" + payload); return payload; } @@ -152,7 +152,7 @@ public void receiveCSPReport(@RequestBody String reportData) { // } } - @ApiOperation(value = "安全代码:特殊字符实体转义", notes = "特殊字符实体转义是一种将 HTML 中的特殊字符转换为预定义实体表示的过程。这种转义是为了确保在 HTML 页面中正确显示特定字符,同时避免它们被浏览器误解为 HTML 标签或JavaScript代码的一部分,从而导致页面结构混乱或安全漏洞。") + @ApiOperation(value = "安全代码:HTML正文输出编码", notes = "将HTML正文文本中的特殊字符编码为实体,避免浏览器把不可信数据解析为HTML标签或JavaScript。不同输出上下文需要使用不同编码策略") @GetMapping("/safe3") @ResponseBody @ApiImplicitParams({ @@ -170,11 +170,11 @@ public R safe3(@ApiParam(name = "type", value = "类型", required = true) @Requ payload = StringUtils.replace(payload, "'", "'"); payload = StringUtils.replace(payload, "/", "/"); filterContented = payload; - log.info("[-]XSS-反射性-内容安全策略-CSP防护-原生实体转移:" + payload); + log.info("[-]XSS-反射型-HTML正文输出编码-手动编码:" + payload); break; case "spring": filterContented = HtmlUtils.htmlEscape(payload); - log.info("[-]XSS-反射性-内容安全策略-CSP防护-Spring框架:" + payload); + log.info("[-]XSS-反射型-HTML正文输出编码-Spring框架:" + payload); break; default: return R.error("参数输入有误!"); @@ -182,7 +182,7 @@ public R safe3(@ApiParam(name = "type", value = "类型", required = true) @Requ return R.ok(filterContented); } - @ApiOperation(value = "安全代码:HttpOnly配置", notes = "HttpOnly是HTTP响应头属性,用于增强Web应用程序安全性。它防止客户端脚本访问(只能通过http/https协议访问)带有HttpOnly标记的 cookie,从而减少跨站点脚本攻击(XSS)的风险。") + @ApiOperation(value = "安全代码:HttpOnly配置", notes = "HttpOnly可以阻止客户端脚本直接读取带有该属性的Cookie,降低XSS窃取Cookie的影响,但不能修复XSS本身") @RequestMapping(value = "/safe4", method = RequestMethod.GET) @ResponseBody @ApiImplicitParam(name = "payload", value = "请求参数", dataType = "String", paramType = "query", dataTypeClass = String.class) diff --git a/src/main/java/top/whgojp/modules/xss/controller/StoreController.java b/src/main/java/top/whgojp/modules/xss/controller/StoreController.java index a4ecf20..1ddb25d 100644 --- a/src/main/java/top/whgojp/modules/xss/controller/StoreController.java +++ b/src/main/java/top/whgojp/modules/xss/controller/StoreController.java @@ -45,7 +45,7 @@ public String xssStore() { return "vul/xss/store"; } - @ApiOperation(value = "漏洞场景:原生无过滤", notes = "原生漏洞场景,未加任何过滤,将用户输入存储到数据库中") + @ApiOperation(value = "漏洞场景:原生无过滤", notes = "原生漏洞场景,未加任何过滤,将用户输入和User-Agent持久化;后续页面不安全渲染时触发存储型XSS") @PostMapping("/vul") @ResponseBody @ApiImplicitParam(name = "payload", value = "请求参数", dataType = "String", paramType = "query", dataTypeClass = String.class) diff --git a/src/main/resources/static/js/staticcode.js b/src/main/resources/static/js/staticcode.js index ee96d45..a5200a6 100755 --- a/src/main/resources/static/js/staticcode.js +++ b/src/main/resources/static/js/staticcode.js @@ -4,7 +4,7 @@ * @email: whgojp@foxmail.com * @Date: 2024/5/19 19:03 */ -const vul1ReflectRaw = "// 原生漏洞场景,未加任何过滤,Controller接口返回Json类型结果\n" + +const vul1ReflectRaw = "// 原生漏洞场景,未加任何过滤,Controller接口返回JSON类型结果\n" + "public R vul1(String payload) {\n" + " return R.ok(payload);\n" + "}\n" + @@ -14,7 +14,7 @@ const vul1ReflectRaw = "// 原生漏洞场景,未加任何过滤,Controller接 "// \"msg\": \"\",\n" + "// \"code\": 0\n" + "// }\n" + - "// payload在json中是不会触发xss的 需要解析到页面中\n" + + "// JSON响应本身通常不会直接执行脚本;前端若把字段用innerHTML等方式写入页面,才会触发XSS\n" + "\n" + "// 原生漏洞场景,未加任何过滤,Controller接口返回String类型结果\n" + "public String vul2(String payload) {\n" + @@ -35,7 +35,7 @@ const vul2ReflectContentType = "// Tomcat内置HttpServletResponse,Content-Typ " ...\n" + " }\n" + "}" -const safe1CheckUserInput = "// 对用户输入的数据进行验证和过滤,确保不包含恶意代码。使用白名单过滤,只允许特定类型的输入,如纯文本或指定格式的数据\n" + +const safe1CheckUserInput = "// 使用白名单限制输入格式,适合约束字段类型;最终仍需根据输出位置进行上下文编码\n" + "// 前端校验代码\n" + "var whitelistRegex = /^[a-zA-Z0-9_\\s]+$/;\n" + "\n" + @@ -55,19 +55,20 @@ const safe1CheckUserInput = "// 对用户输入的数据进行验证和过滤, "if (matcher.matches()){\n" + " return R.ok(payload);\n" + "}else return R.error(\"输入内容包含非法字符,请检查输入\");" -const safe2CSP = "// 内容安全策略(Content Security Policy)是一种由浏览器实施的安全机制,旨在减少和防范跨站脚本攻击(XSS)等安全威胁。它通过允许网站管理员定义哪些内容来源是可信任的,从而防止恶意内容的加载和执行\n" + +const safe2CSP = "// 内容安全策略(Content Security Policy)是浏览器实施的额外防护层,可降低恶意脚本加载和执行风险,但不能替代输出编码与安全DOM/模板用法\n" + "// 前端Meta配置\n" + "\n" + "\n" + "\n" + "// 后端Header配置\n" + "public String safe2(String payload,HttpServletResponse response) {\n" + - " response.setHeader(\"Content-Security-Policy\",\"default-src self\");\n" + + " response.setHeader(\"Content-Security-Policy\", \"default-src 'self'; script-src 'self'\");\n" + + " response.setHeader(\"Content-Security-Policy-Report-Only\", \"default-src 'self'; report-uri /xss/reflect/csp-report-endpoint\");\n" + " return payload;\n" + "}" -const safe3EntityEscape = '// 特殊字符实体转义是一种将HTML中的特殊字符转换为预定义实体表示的过程\n' + - '// 这种转义是为了确保在HTML页面中正确显示特定字符,同时避免它们被浏览器误解为HTML标签或JavaScript代码的一部分,从而导致页面结构混乱或安全漏洞\n' + +const safe3EntityEscape = '// HTML正文输出编码会将特殊字符转换为HTML实体,避免浏览器把不可信数据解析为标签或脚本\n' + + '// 注意:HTML属性、URL、JavaScript字符串、CSS等不同上下文需要使用不同的编码或白名单校验策略\n' + 'public R safe3(@ApiParam(String type, String payload) {\n' + ' String filterContented = "";\n' + ' switch (type){\n' + @@ -87,7 +88,7 @@ const safe3EntityEscape = '// 特殊字符实体转义是一种将HTML中的特 ' }\n' + '}' -const safe4HttpOnly = "// HttpOnly是HTTP响应头属性,用于增强Web应用程序安全性。它防止客户端脚本访问(只能通过http/https协议访问)带有HttpOnly标记的 cookie,从而减少跨站点脚本攻击(XSS)的风险\n" + +const safe4HttpOnly = "// HttpOnly可以阻止客户端脚本直接读取带有该属性的Cookie,降低XSS窃取Cookie的影响,但不能修复XSS本身\n" + "// 单个接口配置\n" + "public R safe4(String payload, HttpServletRequest request,HttpServletResponse response) {\n" + " Cookie cookie = request.getCookies()[ueditor];\n" + @@ -118,7 +119,7 @@ const safe4HttpOnly = "// HttpOnly是HTTP响应头属性,用于增强Web应用 " ...\n" + "}" -const vul1StoreRaw = "// 原生漏洞场景,未加任何过滤,将用户输入存储到数据库中\n" + +const vul1StoreRaw = "// 原生漏洞场景,未加任何过滤,将用户输入和User-Agent持久化;后续页面不安全渲染时触发存储型XSS\n" + "// Controller层\n" + "public R vul(String payload,HttpServletRequest request) {\n" + " String ua = request.getHeader(\"User-Agent\");\n" + @@ -139,7 +140,7 @@ const vul1StoreRaw = "// 原生漏洞场景,未加任何过滤,将用户输入 " values (#{content,jdbcType=VARCHAR},#{ua,jdbcType=VARCHAR}, #{date,jdbcType=VARCHAR})\n" + "" -const safe1StoreEntityEscape = "// 表格数据渲染\n" + +const safe1StoreEntityEscape = "// 表格数据渲染:数据库仍保存原始值,输出到HTML页面前按HTML正文文本编码\n" + "table.render({\n" + "\t...\n" + " cols: [\n" + @@ -151,10 +152,13 @@ const safe1StoreEntityEscape = "// 表格数据渲染\n" + " return escapeHtml(d.ua); \n" + " }},\n" + " \t...\n" + - "// 方法一、HTML 实体转义函数\n" + + "// 方法一、HTML正文输出编码函数\n" + "function escapeHtml(html) {\n" + + " if (html === null || html === undefined) {\n" + + " return '';\n" + + " }\n" + " var text = document.createElement(\"textarea\");\n" + - " text.textContent = html;\n" + + " text.textContent = String(html);\n" + " return text.innerHTML;\n" + "}\n" + "// 方法二、JavaScript的文本节点\n" + @@ -210,11 +214,38 @@ const vul1DomRaw = "// 1. innerHTML XSS\n" + "});\n" + "form.on('submit(document-domain)', function(data) {\n" + " var payload = data.field.documentPayload;\n" + - " document.domain = payload; // 漏洞点:直接修改document.domain\n" + + " document.domain = payload; // 风险点:直接修改document.domain会放宽同源边界或造成异常行为\n" + " return false;\n" + "});" -const vul1OtherUpload = "public String uploadFile(MultipartFile file, String suffix,String path) throws IOException {\n" + +const safeDomCode = "// 1. 普通文本输出:使用textContent,不解析HTML\n" + + "document.getElementById('safe-dom-result').textContent = userInput;\n" + + "\n" + + "// 2. URL跳转:校验协议白名单,拒绝javascript:、data:等危险协议\n" + + "var url = new URL(userInput, window.location.origin);\n" + + "var allowedProtocols = ['http:', 'https:'];\n" + + "if (allowedProtocols.indexOf(url.protocol) === -1) {\n" + + " throw new Error('dangerous protocol');\n" + + "}\n" + + "\n" + + "// 3. 替代eval:使用命令白名单映射,而不是执行用户输入\n" + + "var actions = {\n" + + " showTime: function () { return new Date().toLocaleString(); },\n" + + " showLocation: function () { return window.location.pathname; }\n" + + "};\n" + + "var action = actions[userInput];\n" + + "if (action) {\n" + + " action();\n" + + "}\n" + + "\n" + + "// 4. DOM API:创建文本节点,不拼接HTML字符串\n" + + "var node = document.createTextNode(userInput);\n" + + "element.appendChild(node);\n" + + "\n" + + "// 如果业务必须展示富文本,应先使用白名单HTML净化库处理后再渲染\n" + +const vul1OtherUpload = "// 上传可被浏览器或预览服务解析的HTML/SVG/XML/PDF等文件,后续访问文件时可能触发XSS或内容安全问题\n" + + "public String uploadFile(MultipartFile file, String suffix,String path) throws IOException {\n" + " String uploadFolderPath = sysConstant.getUploadFolder();\n" + " try {\n" + " String fileName = +DateUtil.current() + \".\"+suffix;\n" + @@ -230,7 +261,8 @@ const vul1OtherUpload = "public String uploadFile(MultipartFile file, String suf " }\n" + "}" -const vul2OtherTemplate = "public String handleTemplateInjection(String payload,String type, Model model) {\n" + +const vul2OtherTemplate = "// th:utext会把内容作为HTML渲染;th:text会进行HTML转义\n" + + "public String handleTemplateInjection(String payload,String type, Model model) {\n" + " if (\"html\".equals(type)) {\n" + " model.addAttribute(\"html\", payload);\n" + " } else if (\"text\".equals(type)) {\n" + diff --git a/src/main/resources/templates/vul/xss/dom-href.html b/src/main/resources/templates/vul/xss/dom-href.html index 9b0025a..09db862 100644 --- a/src/main/resources/templates/vul/xss/dom-href.html +++ b/src/main/resources/templates/vul/xss/dom-href.html @@ -14,9 +14,9 @@

Dom型XSS-href跳转demo

- hash 属性是一个可读可写的字符串,该字符串是 URL 的锚部分(从 # 号开始的部分)。当其作为可控参数传入eval()时则会存在DOM型XSS漏洞。 + hash 属性是一个可读可写的字符串,该字符串是 URL 的锚部分(从 # 号开始的部分)。当其作为可控参数进入 location.href、innerHTML、eval() 等危险 Sink 时,可能产生DOM型XSS漏洞。 通过 location.hash 的方式,将参数写在 # 号后,既能让JS读取到该参数, - 又不让该参数传入到服务器,从而避免了WAF的检测。 + 又不让该参数传入到服务器,因此服务端日志或部分网关/WAF可能看不到这段载荷。

变量hash作为可控部分,并带入url中,变量hash控制的是#之后的部分, @@ -38,4 +38,4 @@

Dom型XSS-href跳转demo

} - \ No newline at end of file + diff --git a/src/main/resources/templates/vul/xss/dom.html b/src/main/resources/templates/vul/xss/dom.html index ce41afe..37f4da4 100644 --- a/src/main/resources/templates/vul/xss/dom.html +++ b/src/main/resources/templates/vul/xss/dom.html @@ -12,7 +12,7 @@
  DOM(Document Object Model)即文档对象模型,是HTML和XML文档的编程接口
-
  DOM型XSS:攻击者利用客户端的DOM环境,通过操纵页面的DOM元素来注入和执行恶意脚本。该攻击不经过服务器和数据库
+
  DOM型XSS:不可信数据在客户端JavaScript中流向危险DOM Sink,例如innerHTML、document.write、location、eval等,最终被浏览器解析执行。典型载荷可来自location.hash、location.search、localStorage、postMessage等;其中hash场景通常不会发送到服务器,但DOM XSS并不等于“绝对不经过服务端”,关键判断点是漏洞触发和危险写入发生在客户端。
@@ -90,7 +90,7 @@

漏洞场景:多种代码场景
-

使用#实现页面跳转,但是因为跳转部分参数可控,可能导致DOM XSS

+

使用#实现页面跳转。hash不会随HTTP请求发送到服务端,但前端脚本读取后如果直接赋值给location.href,可能触发DOM XSS

@@ -172,7 +172,9 @@

漏洞场景:多种代码场景
tips
-
  一些可能导致DOM XSS的SINK点:document.write()、document.writeln()、document.domain、element.innerHTML、element.outerHTML、element.insertAdjacentHTML、element.onevent、location.href、location.hash、location.search、eval()、setTimeout()、setInterval()、Function()
+
  DOM XSS可以按“Source -> Sink”审计:
+  常见Source:location.href、location.search、location.hash、document.referrer、localStorage、sessionStorage、postMessage消息、WebSocket消息
+  常见Sink:document.write()、element.innerHTML、element.outerHTML、insertAdjacentHTML()、事件属性、location跳转、eval()、Function()、setTimeout字符串参数

@@ -203,6 +205,112 @@

缺陷代码

+
+
+
+

安全场景:按上下文安全写入

+
+
    +
  • 文本输出
  • +
  • URL跳转
  • +
  • 替代eval
  • +
  • DOM API
  • +
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+
+
tips
+
+
DOM XSS修复重点:
+  1. 普通文本输出使用textContent、innerText或createTextNode,不要使用innerHTML
+  2. URL跳转前校验协议和目标域名,拒绝javascript:、data:等危险协议
+  3. 不要把用户输入交给eval、Function、setTimeout字符串参数执行
+  4. 需要展示富文本时,使用白名单HTML净化库,再按业务允许的标签和属性渲染
+
+
+
+ +
+
+
测试结果
+
+

+                                        
+
+
+
+
+
+ +
+

安全代码

+
+
+
+
+
+ +
+
+ @@ -269,6 +377,52 @@

缺陷代码

CodeMirror(document.getElementById("vul1DomRaw"), Object.assign({}, cmConfig, { value: vul1DomRaw })); + CodeMirror(document.getElementById("safeDomCode"), Object.assign({}, cmConfig, { + theme: 'juejinsafe', + value: safeDomCode + })); + + form.on('submit(safe-dom-text)', function (data) { + document.getElementById('safe-dom-result').textContent = data.field.safeTextPayload; + return false; + }); + + form.on('submit(safe-dom-url)', function (data) { + var rawUrl = data.field.safeUrlPayload; + try { + var url = new URL(rawUrl, window.location.origin); + var allowedProtocols = ['http:', 'https:']; + if (allowedProtocols.indexOf(url.protocol) === -1) { + document.getElementById('safe-dom-result').textContent = '已拦截危险协议:' + url.protocol; + return false; + } + document.getElementById('safe-dom-result').textContent = 'URL校验通过:' + url.href; + } catch (e) { + document.getElementById('safe-dom-result').textContent = 'URL格式非法:' + e.message; + } + return false; + }); + + form.on('submit(safe-dom-command)', function (data) { + var actions = { + showTime: function () { + return '当前时间:' + new Date().toLocaleString(); + }, + showLocation: function () { + return '当前路径:' + window.location.pathname; + } + }; + var action = actions[data.field.safeCommand]; + document.getElementById('safe-dom-result').textContent = action ? action() : '未知命令,拒绝执行'; + return false; + }); + + form.on('submit(safe-dom-node)', function (data) { + var result = document.getElementById('safe-dom-result'); + result.textContent = ''; + result.appendChild(document.createTextNode(data.field.safeNodePayload)); + return false; + }); }); }); diff --git a/src/main/resources/templates/vul/xss/other.html b/src/main/resources/templates/vul/xss/other.html index 7369d3a..3a3400c 100644 --- a/src/main/resources/templates/vul/xss/other.html +++ b/src/main/resources/templates/vul/xss/other.html @@ -10,7 +10,7 @@ 跨站脚本攻击 - 其他场景
-
  其他场景:包含模版引擎解析问题、文件上传特殊文件类型、第三方依赖问题(供应链安全)、特殊场景XSS...
+
  其他场景:包含模板引擎不安全渲染、可执行/可解析文件上传、第三方组件漏洞,以及WebSocket、postMessage等HTML5通信场景中的XSS。
@@ -20,14 +20,14 @@

- 漏洞场景:模版引擎解析问题 + 漏洞场景:模板引擎不安全渲染
@@ -37,7 +37,7 @@

    -
  • th:html
  • +
  • th:utext
  • th:text
@@ -102,9 +102,9 @@

tips
-
th:text用于展示纯文本,会对特殊字符进行转义
-th:utext则不进行转义,直接展示原始HTML内容
-当获取后端传来的参数中带有HTML标签时,th:text不会解析这些标签,而th:utext 会解析并渲染它们。这类似于Vue中的v-text和v-html
+
  th:text用于展示纯文本,会对特殊字符进行转义,适合普通文本输出
+  th:utext不会进行HTML转义,会直接把内容作为HTML渲染,只有在内容可信或已被白名单净化时才应使用
+  当后端传入参数中带有HTML标签时,th:text不会解析这些标签,而th:utext会解析并渲染它们。这类似于Vue中的v-text和v-html

@@ -234,11 +234,9 @@

tips
-
-除了文件上传导致存储XSS,xml场景下还需要后端进行xml解析
-这里PDF型XSS实际是没有危害的,考虑到合规监管问题,还是放上去了
-PS:除此之外,还有flash等漏洞场景,后续会补充
-                                            
+
  文件上传类XSS通常出现在上传内容可被浏览器直接解析,或后端/预览服务会解析文件内容的场景
+  HTML/SVG如果以可执行内容类型访问,可能直接执行脚本;XML场景常见于后端解析或前端预览;PDF脚本执行能力受阅读器和浏览器实现限制,通常风险较低但仍适合作为文件内容安全案例
+  修复方向:限制后缀与MIME、校验文件魔数、使用随机文件名、独立文件域名、强制下载或设置安全Content-Type,并对可预览内容做净化

diff --git a/src/main/resources/templates/vul/xss/reflect/safe.html b/src/main/resources/templates/vul/xss/reflect/safe.html index 255421b..dcc4d0b 100644 --- a/src/main/resources/templates/vul/xss/reflect/safe.html +++ b/src/main/resources/templates/vul/xss/reflect/safe.html @@ -12,8 +12,8 @@
-
  XSS(跨站脚本攻击)利用浏览器对服务器内容的信任,攻击者通过在网页中注入恶意脚本,使这些脚本在用户的浏览器上执行,从而实现攻击。常见的XSS攻击危害包括窃取用户会话信息、篡改网页内容、将用户重定向到恶意网站,以及执行恶意操作(如点击劫持和钓鱼攻击)
-  反射型XSS:攻击者通过在URL参数中注入恶意脚本,使服务器将该脚本直接反射回用户浏览器并执行。该攻击一般不涉及数据库,而是通过服务器处理用户请求时立即返回恶意内容
+
  XSS(跨站脚本攻击)的本质是:不可信数据进入页面执行上下文后,被浏览器当作脚本或可执行HTML解析。修复时不要只依赖“过滤某个关键词”,而要结合输入校验、上下文输出编码、模板安全用法和CSP等多层防护。
+  反射型XSS:攻击载荷通常来自URL参数、表单、Header或路径等请求数据,服务端未做合适的输出编码或上下文处理,就把载荷立即返回到响应页面中执行。该类型通常不依赖数据库,特点是“请求即触发”。

@@ -74,10 +74,8 @@

安全场景:用户输入白名单过
tips
-
-前端白名单:在前端代码中执行,用于过滤或验证用户输入的数据,UI友好,不安全,容易被绕过
-后端白名单:后端业务逻辑处理或数据支持化层面执行,更安全,不易绕过
-                                            
+
  前端白名单:用于改善交互体验和提前提示用户,不能作为安全边界,攻击者可以绕过浏览器直接请求接口
+  后端白名单:在服务端业务逻辑或数据持久化前执行,适合限制“应该输入什么”,但仍不能替代最终输出位置的上下文编码
测试结果
@@ -138,14 +136,14 @@

安全场景:内容安全策略-CSP防
tips
-
  内容安全策略(CSP:Content Security Policy)是一种由浏览器实施的安全机制(可理解为额外的安全层),旨在减少和防范跨站脚本攻击等安全威胁
-  核心原理:网站通过发送一个CSP header头部(也可以在html直接设置),告诉浏览器具体的策略(什么是授权的与什么是被禁止的),从而防止恶意内容的加载和执行
+                                            
  内容安全策略(CSP:Content Security Policy)是一种由浏览器实施的安全机制,可作为XSS的额外防护层,但不能替代输出编码和安全DOM/模板用法
+  核心原理:网站通过发送CSP Header头部(也可以在HTML中通过meta设置),告诉浏览器哪些资源来源、脚本执行方式是允许的,从而降低恶意内容加载和执行的风险
   CSP 指令说明:
-    default-src: 指定默认的加载内容的来源,如果未指定其他指令,则默认应用此指令
-    script-src: 指定允许加载 JavaScript 的来源
-    style-src: 指定允许加载样式表的来源
-    img-src: 指定允许加载图片的来源
-    connect-src: 指定允许向其发送请求的来源(例如 AJAX、WebSocket 连接等)
+ default-src: 指定默认的加载内容的来源,如果未指定其他指令,则默认应用此指令 + script-src: 指定允许加载 JavaScript 的来源 + style-src: 指定允许加载样式表的来源 + img-src: 指定允许加载图片的来源 + connect-src: 指定允许向其发送请求的来源(例如 AJAX、WebSocket 连接等)

@@ -165,7 +163,7 @@

安全代码

-

安全场景:特殊字符实体转义

+

安全场景:上下文输出编码

  • Manual自定义
  • @@ -220,9 +218,11 @@

    安全场景:特殊字符实体转义<
    tips
    -实体编码(Entity Encoding)是一种将特殊字符转换为HTML实体的过程,以确保这些字符能够在HTML文档中正确显示而不会被解释为HTML标记。
    -常见的实体编码包括将 < 转换为 &lt; 将 > 转换为 &gt; 等。
    -PS:这里前端实体编码Demo放到了存储型XSS模块中
    +输出编码需要匹配具体输出上下文:
    +  1. HTML正文文本:将 <、>、&、"、' 等字符编码为HTML实体
    +  2. HTML属性、URL、JavaScript字符串、CSS等位置:需要使用对应上下文的编码或白名单校验
    +  3. 富文本场景:单纯编码会破坏格式,通常应使用HTML白名单净化库
    +PS:这里演示的是HTML正文文本场景的编码,存储型XSS模块中还有表格渲染阶段的输出编码示例
                                                 
    @@ -287,12 +287,10 @@

    安全场景:HttpOnly配置

    tips
    -
      单个接口配置:并非所有的cookie都必须设置为HttpOnly,可能有一些cookie是需要客户端JavaScript访问的,例如用于前端操作或分析目的的cookie
    +                                            
      HttpOnly不能修复XSS本身,它只能降低“脚本直接读取Cookie”的风险。页面一旦存在XSS,攻击者仍可能发起用户态请求、篡改页面或诱导操作。
    +  单个接口配置:并非所有的cookie都必须设置为HttpOnly,可能有一些cookie是需要客户端JavaScript访问的,例如用于前端操作或分析目的的cookie
       全局配置场景:在整个应用程序中所有的cookie都具有HttpOnly属性,可以考虑在全局配置中进行设置
    -使用HttpOnly并不是绝对安全的,以下三个场景还是会存在安全问题:
    -    1、当攻击者使用CSRF+XSS进行攻击时,可绕过绕过浏览器的安全限制
    -    2、中间人攻击
    -    3、恶意浏览器插件
    + 建议同时配合Secure、SameSite、会话失效策略、CSRF防护和前面的输出编码/CSP策略。
    测试结果
    diff --git a/src/main/resources/templates/vul/xss/reflect/vul.html b/src/main/resources/templates/vul/xss/reflect/vul.html index f44190b..dd1c4c2 100644 --- a/src/main/resources/templates/vul/xss/reflect/vul.html +++ b/src/main/resources/templates/vul/xss/reflect/vul.html @@ -12,8 +12,8 @@
    -
      XSS(跨站脚本攻击)利用浏览器对服务器内容的信任,攻击者通过在网页中注入恶意脚本,使这些脚本在用户的浏览器上执行,从而实现攻击。常见的XSS攻击危害包括窃取用户会话信息、篡改网页内容、将用户重定向到恶意网站,以及执行恶意操作(如点击劫持和钓鱼攻击)
    -  反射型XSS:攻击者通过在URL参数中注入恶意脚本,使服务器将该脚本直接反射回用户浏览器并执行。该攻击一般不涉及数据库,而是通过服务器处理用户请求时立即返回恶意内容
    +
      XSS(跨站脚本攻击)的本质是:不可信数据进入页面执行上下文后,被浏览器当作脚本或可执行HTML解析。常见危害包括窃取会话信息、篡改页面内容、跳转钓鱼页面、诱导用户执行敏感操作等。
    +  反射型XSS:攻击载荷通常来自URL参数、表单、Header或路径等请求数据,服务端未做合适的输出编码或上下文处理,就把载荷立即返回到响应页面中执行。该类型通常不依赖数据库,特点是“请求即触发”。
    @@ -85,9 +85,9 @@

    漏洞场景:GET型与POST型

    tips
    -
    Java后端返回数据有以下几种形式:
    +                                            
    Java后端返回数据有以下几种形式。是否触发XSS取决于最终Content-Type和前端如何把数据写入页面:
         响应String字符串:返回一个字符串作为响应,一般用于返回纯文本数据
    -    响应JSON数据:返回一个JSON对象作为响应,适用于前后端分离或移动端接口
    +    响应JSON数据:返回一个JSON对象作为响应,单纯JSON响应一般不会直接执行脚本,但前端若用innerHTML等方式渲染字段仍可能触发XSS
         响应XML数据:返回一个XML对象作为响应,适用于与其他系统进行数据交互
         响应二进制数据:返回一个字节数组或文件流作为响应,适用于文件下载或图片等二进制数据的返回
         响应数据流:返回一个数据流对象作为响应,适用于大文件或大数据量的返回
    @@ -157,7 +157,7 @@ 

    漏洞场景:Content-Type问题tips

    -Content-Type属性指定响应的HTTP内容类型。如果未指定ContentType,默认为text/html
    +  Content-Type属性指定响应的HTTP内容类型。安全场景中应明确返回text/plain、application/json等合适类型;如果把不可信内容以text/html返回,浏览器会按HTML解析,XSS风险会显著增加
                                                 
    diff --git a/src/main/resources/templates/vul/xss/store.html b/src/main/resources/templates/vul/xss/store.html index 50958f0..4436700 100644 --- a/src/main/resources/templates/vul/xss/store.html +++ b/src/main/resources/templates/vul/xss/store.html @@ -10,7 +10,7 @@ 跨站脚本攻击 - 存储型XSS
    -
      存储型XSS:攻击者将恶意脚本上传到目标网站的数据库,用户访问网站时执行这些恶意脚本,达到攻击目的。该攻击经过服务器和数据库
    +
      存储型XSS:攻击者把恶意内容提交到目标系统,系统将其保存到数据库、缓存、文件、对象存储、日志等持久化位置;其他用户访问相关页面时,服务端或前端把这段内容取出并以不安全方式渲染,最终在用户浏览器中执行。该类型的关键特征是“先存储、后触发”,不一定只经过数据库。
    @@ -66,8 +66,9 @@

    tips

    漏洞场景:
    -    用户交互的地方:get、post、headers、反馈与浏览、富文本编辑器、标签插入和自定义
    -    数据输出的地方:用户资料、关键词、评论、留言、关键词、标签、说明、文件上传
    + 输入来源:GET/POST参数、Header、User-Agent、评论、留言、富文本编辑器、标签、自定义字段、文件元数据 + 持久化位置:数据库、缓存、文件、对象存储、搜索索引、日志系统 + 输出位置:用户资料、评论列表、后台审核页、关键词、标签、说明、文件预览页等
    @@ -108,7 +109,7 @@

    缺陷代码

    -

    安全场景:前端实体转义

    +

    安全场景:输出编码

    @@ -150,7 +151,12 @@

    安全场景:前端实体转义
    tips
    -
      前端实体转义就是将可能是包含HTML标签的内容,先中转存储为纯文本,当浏览器进行渲染时,会将纯文本内容中特殊字符进行实体转义(浏览器策略),确保可以正确展示
    +
    存储型XSS的关键修复点是“危险数据进入页面前必须按输出上下文处理”:
    +  1. 普通文本展示:输出时进行HTML实体编码,避免浏览器把内容当作标签或脚本解析
    +  2. 富文本业务:入库前使用白名单净化,只允许业务需要的标签、属性和协议
    +  3. URL、HTML属性、JavaScript、CSS等不同上下文,需要使用对应的上下文编码或白名单校验
    +
    +当前安全场景用于演示表格渲染阶段的输出编码:数据库中仍保存原始输入,但页面展示时将Content和User-Agent编码后再渲染。真实业务中还应根据字段类型做输入约束,并对富文本等特殊内容做白名单净化。

    @@ -330,8 +336,11 @@

    安全代码

    // HTML 实体转义函数 function escapeHtml(html) { + if (html === null || html === undefined) { + return ''; + } var text = document.createElement("textarea"); - text.textContent = html; + text.textContent = String(html); return text.innerHTML; } From 35af3109ebdceffb2ab5350aa90be8a4d6378b33 Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Mon, 25 May 2026 21:25:11 +0800 Subject: [PATCH 02/19] Optimize SQL injection module --- docs/instructions.md | 107 ++++ .../whgojp/common/utils/CheckUserInput.java | 42 +- .../sqli/controller/HibernateController.java | 7 +- .../sqli/controller/JPAController.java | 71 ++- .../sqli/controller/JdbcController.java | 196 ++++++-- .../sqli/controller/MyBatisController.java | 43 +- src/main/resources/mapper/SqliMapper.xml | 10 +- src/main/resources/static/js/staticcode.js | 476 +++++++++++++----- .../templates/vul/sqli/hibernate.html | 14 +- .../templates/vul/sqli/jdbcSafe.html | 12 +- .../templates/vul/sqli/jdbcSpecial.html | 217 +++++++- .../resources/templates/vul/sqli/jdbcVul.html | 15 +- .../resources/templates/vul/sqli/jpa.html | 147 ++++-- .../resources/templates/vul/sqli/mybatis.html | 23 +- 14 files changed, 1116 insertions(+), 264 deletions(-) create mode 100644 docs/instructions.md diff --git a/docs/instructions.md b/docs/instructions.md new file mode 100644 index 0000000..3cda8cd --- /dev/null +++ b/docs/instructions.md @@ -0,0 +1,107 @@ +# 常规漏洞 + +## 跨站脚本 + + + +## SQL注入 +SQL注入模块适合作为综合性Java靶场的核心模块:当前覆盖了JDBC原生拼接、伪预编译拼接、JdbcTemplate拼接、参数化查询、MyBatis动态SQL、Hibernate HQL/原生SQL、JPA JPQL/动态排序等常见开发栈。 + +本次已将页面描述统一为:SQL注入的本质是不可信输入进入SQL语法结构并改变原SQL语义;修复优先使用参数化查询,列名、表名、排序方向等SQL结构必须使用枚举或白名单映射。黑名单、类型校验、ESAPI编码只作为辅助方案,不应作为首选修复方案。 + +已覆盖类型 + +| 分类 | 已有场景 | 结论 | +| --- | --- | --- | +| JDBC | 原生SQL拼接、伪预编译拼接、JdbcTemplate拼接 | 覆盖基础漏洞成因,适合新手理解 | +| JDBC安全写法 | PreparedStatement、JdbcTemplate参数绑定 | 覆盖常规DML安全修复 | +| 辅助方案 | 黑名单、数据类型校验、ESAPI encodeForSQL | 可保留,但页面应强调非首选修复 | +| 特殊结构与利用链 | ORDER BY、LIKE、LIMIT、二次SQL注入、UNION回显 | 覆盖参数绑定无法直接处理SQL结构,以及存储型链路和数据回显利用 | +| MyBatis | 内置方法、自定义`#{}`、`${}` ORDER BY/LIKE/IN、foreach | 覆盖MyBatis典型误区 | +| Hibernate | 原生SQL、HQL、setParameter | 覆盖ORM下仍会注入的情况 | +| JPA | JPQL、动态排序、命名参数、Criteria白名单 | 覆盖JPA常见风险点 | + +### JDBC漏洞场景测试 + +页面:`/sqli/jdbc/jdbcVul` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 原生拼接-查询 | `GET /sqli/jdbc/vul1?type=select&id=1 OR 1=1` | `id=1 OR 1=1` | 漏洞场景返回多条或异常中泄露SQL行为 | +| 原生拼接-新增报错 | `GET /sqli/jdbc/vul1?type=add` | `password=1' and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1) AND '1'='1` | 返回数据库报错信息或执行异常 | +| 伪预编译 | `GET /sqli/jdbc/vul2?type=select&id=1 OR 1=1` | `id=1 OR 1=1` | 仍可被注入,证明先拼SQL再prepare无效 | +| JdbcTemplate拼接 | `GET /sqli/jdbc/vul3?type=select&id=1 OR 1=1` | `id=1 OR 1=1` | 仍可被注入 | +| 流量包下载 | 页面右上角流量分析下拉 | 延时/布尔/报错/Xpath | 选择后应下载对应`pcapng`文件 | + +### JDBC安全与辅助场景测试 + +页面:`/sqli/jdbc/jdbcSafe` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| PreparedStatement | `GET /sqli/jdbc/safe1?type=select&id=1 OR 1=1` | 非数字或注入payload | 不执行注入语义,返回参数错误或查询失败 | +| JdbcTemplate参数绑定 | `GET /sqli/jdbc/safe2?type=select&id=1 OR 1=1` | 注入payload | 不执行注入语义 | +| 黑名单辅助 | `GET /sqli/jdbc/safe3?type=select&id=1 and sleep(5)` | 含黑名单关键字 | 拦截并提示“黑名单检测到非法SQL注入” | +| 数据类型校验 | `GET /sqli/jdbc/safe4?id=1' or '1'='1` | 字符型注入 | 拒绝非整数输入 | +| ESAPI辅助 | `GET /sqli/jdbc/safe5?id=1' or '1'='1` | 字符型注入 | 编码后不应改变SQL语义,但不作为首选修复验收 | + +### JDBC特殊场景测试 + +页面:`/sqli/jdbc/jdbcSpecial` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| ORDER BY拼接 | `GET /sqli/jdbc/special1-OrderBy?type=raw&field=username and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1)%23` | 动态排序字段注入 | 漏洞路径出现异常或泄露 | +| ORDER BY占位符误区 | `GET /sqli/jdbc/special1-OrderBy?type=prepareStatement&field=username` | 合法字段 | 不能真正按字段排序,用于说明占位符不能绑定SQL结构 | +| ORDER BY白名单 | `GET /sqli/jdbc/special1-OrderBy?type=writeList&field=username` | 合法字段 | 正常返回排序结果 | +| ORDER BY白名单拦截 | `GET /sqli/jdbc/special1-OrderBy?type=writeList&field=username desc` | 非白名单字段 | 返回字段不合法 | +| LIKE拼接 | `GET /sqli/jdbc/special2-Like?type=raw&keyword=1' OR '1'='1` | LIKE注入 | 漏洞路径被触发 | +| LIKE参数绑定 | `GET /sqli/jdbc/special2-Like?type=prepareStatement&keyword=admin' OR '1'='1` | LIKE注入 | 作为普通关键词处理 | +| LIMIT参数 | `GET /sqli/jdbc/special3-Limit?type=prepareStatement&size=1` | 正整数 | 正常返回限制条数 | +| 二次注入-写入 | `GET /sqli/jdbc/special4-SecondOrder?type=store&username=second_order' OR '1'='1&password=demo` | 恶意用户名 | 使用参数化写入成功,不在第一步触发 | +| 二次注入-触发 | `GET /sqli/jdbc/special4-SecondOrder?type=trigger&id=<写入返回ID>` | 已存储恶意用户名 | 第二次查询拼接数据库中的username,返回多条记录或表现出注入效果 | +| 二次注入-安全对照 | `GET /sqli/jdbc/special4-SecondOrder?type=safeTrigger&id=<写入返回ID>` | 已存储恶意用户名 | 使用参数绑定,恶意内容作为普通username查询 | +| UNION回显 | `GET /sqli/jdbc/special5-Union?type=raw&id=-1 UNION SELECT 1,database(),user()` | UNION Payload | 回显当前数据库名和数据库用户 | +| UNION参数绑定 | `GET /sqli/jdbc/special5-Union?type=prepareStatement&id=-1 UNION SELECT 1,database(),user()` | UNION Payload | Payload作为普通参数,不改变SQL结构 | + +### MyBatis场景测试 + +页面:`/sqli/mybatis` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 内置方法 | `POST /sqli/mybatis/safe1?type=select&id=2` | 合法ID | 正常查询 | +| 内置方法缺少ID | `POST /sqli/mybatis/safe1?type=delete` | 缺少ID | 返回`id不能为空!` | +| 自定义`#{}` | `POST /sqli/mybatis/safe2?type=select&id=999999` | 不存在ID | 返回`用户ID不存在!`,不出现空指针 | +| ORDER BY `${}` | `POST /sqli/mybatis/special1-OrderBy?type=raw&field=username and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1)%23` | 动态字段注入 | 漏洞路径被触发 | +| ORDER BY `#{}`误区 | `POST /sqli/mybatis/special1-OrderBy?type=prepareStatement&field=username` | 合法字段 | 不能作为真正动态字段排序 | +| ORDER BY白名单 | `POST /sqli/mybatis/special1-OrderBy?type=writeList&field=username` | 合法字段 | 正常返回 | +| LIKE `${}` | `POST /sqli/mybatis/special2-Like?type=raw&keyword=1' OR '1'='1` | LIKE注入 | 漏洞路径被触发 | +| LIKE `#{}` | `POST /sqli/mybatis/special2-Like?type=prepareStatement&keyword=admin' OR '1'='1` | LIKE注入 | 作为普通关键词处理 | +| IN `${}` | `POST /sqli/mybatis/special3-In?type=raw&scope=1) OR 1=1 -- ` | IN注入 | 漏洞路径被触发 | +| IN `#{}`误区 | `POST /sqli/mybatis/special3-In?type=prepareStatement&scope=1,2` | 多ID | 作为单个参数处理,不能展开多个占位 | +| IN foreach | `POST /sqli/mybatis/special3-In?type=Foreach&scope=1,2,abc` | 混入非法值 | 忽略非法值,使用合法整数查询 | +| IN foreach空值 | `POST /sqli/mybatis/special3-In?type=Foreach` | 缺少scope | 返回`scope中没有合法整数ID!` | + +### Hibernate与JPA场景测试 + +页面:`/sqli/hibernate` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| Hibernate原生SQL | `POST /sqli/hibernate/vul1?username=admin' OR 1=1 OR '1'='1` | 注入payload | 返回多条记录或表现出注入效果 | +| Hibernate HQL | `GET /sqli/hibernate/vul2?username=admin' OR 1=1 OR '1'='1` | 注入payload | 返回多条记录或表现出注入效果 | +| Hibernate参数化 | `POST /sqli/hibernate/safe?username=admin' OR 1=1 OR '1'='1` | 注入payload | 当作普通用户名,返回未找到记录 | + +页面:`/sqli/jpa` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| JPA JPQL | `GET /sqli/jpa/vul1?username=admin' OR '1'='1` | 注入payload | 返回多条记录或表现出注入效果 | +| JPA动态排序 | `GET /sqli/jpa/vul2?orderBy=username desc` | 动态排序字段 | 正常排序;异常payload应暴露风险 | +| JPA参数化 | `GET /sqli/jpa/safe?username=admin' OR '1'='1` | 注入payload | 当作普通用户名,返回未找到记录 | +| JPA排序白名单 | `GET /sqli/jpa/safe-order?orderBy=username` | 合法字段 | 正常排序 | +| JPA排序白名单拦截 | `GET /sqli/jpa/safe-order?orderBy=username desc` | 非白名单字段 | 返回排序字段不合法 | + + +# Java 专题 diff --git a/src/main/java/top/whgojp/common/utils/CheckUserInput.java b/src/main/java/top/whgojp/common/utils/CheckUserInput.java index 8f191d8..40706e9 100644 --- a/src/main/java/top/whgojp/common/utils/CheckUserInput.java +++ b/src/main/java/top/whgojp/common/utils/CheckUserInput.java @@ -2,11 +2,14 @@ import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; +import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.regex.Pattern; /** @@ -117,9 +120,12 @@ public boolean checkSqlWhiteList(String content) { * 文件上传白名单 */ public boolean checkFileSuffixWhiteList(String suffix) { + if (suffix == null || suffix.isEmpty()) { + return false; + } String[] white_list = {"jpg", "png", "gif","jpeg","bmp","ico"}; for (String s : white_list) { - if (suffix.toLowerCase().contains(s)) { + if (suffix.equalsIgnoreCase(s)) { return true; } } @@ -151,7 +157,13 @@ public boolean checkURL(String url) { * ssrf:判断http(s)协议 */ public boolean isHttp(String url){ - return url.startsWith("http://") || url.startsWith("https://"); + try { + URI uri = new URI(url); + String scheme = uri.getScheme(); + return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); + } catch (URISyntaxException e) { + return false; + } } /** * ssrf:请求域名白名单 @@ -159,13 +171,33 @@ public boolean isHttp(String url){ public boolean ssrfWhiteList(String url) { List urlList = new ArrayList<>(Arrays.asList("baidu.com", "www.baidu.com", "whgojp.top")); try { - URI uri = new URI(url.toLowerCase()); + URI uri = new URI(url); String host = uri.getHost(); - return urlList.contains(host); - } catch (URISyntaxException e) { + if (host == null || uri.getUserInfo() != null) { + return false; + } + return urlList.contains(host.toLowerCase(Locale.ROOT)) && !isInternalHost(host); + } catch (URISyntaxException | UnknownHostException e) { System.out.println(e); return false; } } + /** + * SSRF:解析域名后的所有IP都不能落入内网、回环、链路本地等地址段。 + */ + private boolean isInternalHost(String host) throws UnknownHostException { + InetAddress[] addresses = InetAddress.getAllByName(host); + for (InetAddress address : addresses) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return true; + } + } + return false; + } + } diff --git a/src/main/java/top/whgojp/modules/sqli/controller/HibernateController.java b/src/main/java/top/whgojp/modules/sqli/controller/HibernateController.java index 16c04d1..d7399a6 100644 --- a/src/main/java/top/whgojp/modules/sqli/controller/HibernateController.java +++ b/src/main/java/top/whgojp/modules/sqli/controller/HibernateController.java @@ -30,7 +30,6 @@ public String sqliHibernate() { @Autowired(required = true) private HibernateTemplate hibernateTemplate; - String message = ""; @RequestMapping("/vul1") @ResponseBody @@ -57,7 +56,7 @@ public R vul1(@RequestParam String username) { .append(", 密码: ").append(row[2]) .append("\n"); } - message = sb.toString(); + String message = sb.toString(); log.info(message); return R.ok(message); } catch (Exception e) { @@ -88,7 +87,7 @@ public R vul2(@RequestParam String username) { .append(", 密码: ").append(sqli.getPassword()) .append("\n"); } - message = sb.toString(); + String message = sb.toString(); log.info(message); return R.ok(message); } catch (Exception e) { @@ -121,7 +120,7 @@ public R safe(@RequestParam String username) { .append(", 密码: ").append(sqli.getPassword()) .append("\n"); } - message = sb.toString(); + String message = sb.toString(); log.info(message); return R.ok(message); } catch (Exception e) { diff --git a/src/main/java/top/whgojp/modules/sqli/controller/JPAController.java b/src/main/java/top/whgojp/modules/sqli/controller/JPAController.java index 2c1ebc9..51303da 100644 --- a/src/main/java/top/whgojp/modules/sqli/controller/JPAController.java +++ b/src/main/java/top/whgojp/modules/sqli/controller/JPAController.java @@ -15,7 +15,9 @@ import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Root; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * @description JPA SQL注入漏洞演示 @@ -35,7 +37,6 @@ public String sqliJpa() { @PersistenceContext private EntityManager entityManager; - String message = ""; @RequestMapping("/vul1") @ResponseBody @@ -58,7 +59,7 @@ public R vul1(@RequestParam String username) { .append(", 密码: ").append(sqli.getPassword()) .append("\n"); } - message = sb.toString(); + String message = sb.toString(); log.info(message); return R.ok(message); } catch (Exception e) { @@ -68,6 +69,23 @@ public R vul1(@RequestParam String username) { } } + @RequestMapping("/vul2") + @ResponseBody + @AuthIgnore + @ApiOperation(value = "JPA动态排序注入场景") + @Transactional(rollbackFor = Exception.class) + public R vul2(@RequestParam String orderBy) { + try { + String jpql = "SELECT s FROM Sqli s ORDER BY s." + orderBy; + Query query = entityManager.createQuery(jpql); + List results = query.getResultList(); + return R.ok(formatResults(results)); + } catch (Exception e) { + String errorMsg = e.getMessage(); + log.error("查询失败: {}", errorMsg, e); + return R.error(errorMsg); + } + } @RequestMapping("/safe") @ResponseBody @@ -90,7 +108,7 @@ public R safe(@RequestParam String username) { .append(", 密码: ").append(sqli.getPassword()) .append("\n"); } - message = sb.toString(); + String message = sb.toString(); log.info(message); return R.ok(message); } catch (Exception e) { @@ -99,4 +117,49 @@ public R safe(@RequestParam String username) { return R.error(errorMsg); } } -} \ No newline at end of file + + @RequestMapping("/safe-order") + @ResponseBody + @ApiOperation(value = "JPA动态排序安全场景") + @Transactional(rollbackFor = Exception.class) + public R safeOrder(@RequestParam String orderBy) { + try { + Map orderByMap = new HashMap<>(); + orderByMap.put("id", "id"); + orderByMap.put("username", "username"); + orderByMap.put("password", "password"); + + String safeOrderBy = orderByMap.get(orderBy); + if (safeOrderBy == null) { + return R.error("排序字段不合法"); + } + + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Sqli.class); + Root root = cq.from(Sqli.class); + cq.select(root).orderBy(cb.asc(root.get(safeOrderBy))); + + List results = entityManager.createQuery(cq).getResultList(); + return R.ok(formatResults(results)); + } catch (Exception e) { + String errorMsg = e.getMessage(); + log.error("查询失败: {}", errorMsg, e); + return R.error(errorMsg); + } + } + + private String formatResults(List results) { + if (results == null || results.isEmpty()) { + return "未找到记录"; + } + StringBuilder sb = new StringBuilder(); + sb.append("查询成功,找到 ").append(results.size()).append(" 条记录\n"); + for (Sqli sqli : results) { + sb.append("ID: ").append(sqli.getId()) + .append(", 用户名: ").append(sqli.getUsername()) + .append(", 密码: ").append(sqli.getPassword()) + .append("\n"); + } + return sb.toString(); + } +} diff --git a/src/main/java/top/whgojp/modules/sqli/controller/JdbcController.java b/src/main/java/top/whgojp/modules/sqli/controller/JdbcController.java index b099981..989d74c 100644 --- a/src/main/java/top/whgojp/modules/sqli/controller/JdbcController.java +++ b/src/main/java/top/whgojp/modules/sqli/controller/JdbcController.java @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.*; import java.sql.*; +import java.util.List; import java.util.Map; @@ -60,7 +61,7 @@ public String sqliJdbcSafe() { @RequestMapping("/jdbcSpecial") public String sqliJdbcSpecial() { - return "vul/sqli/JdbcSpecial"; + return "vul/sqli/jdbcSpecial"; } @ApiOperation(value = "漏洞场景:JDBC-原生SQL语句拼接", notes = "原生sql语句动态拼接 参数未进行任何处理") @@ -290,19 +291,12 @@ public R vul3( return R.ok(message); case "select": sql = "SELECT * FROM sqli WHERE id = " + id; - Map stringObjectMap; - try { - stringObjectMap = jdbctemplate.queryForMap(sql); - } catch (EmptyResultDataAccessException e) { + List> resultList = jdbctemplate.queryForList(sql); + if (resultList.isEmpty()) { return R.error("用户ID不存在"); } - final JSONObject jsonObject = JSONUtil.createObj(); - jsonObject.put("result", stringObjectMap); - log.info(stringObjectMap.toString()); - String user = (String) stringObjectMap.get("username"); - String pass = (String) stringObjectMap.get("password"); - - message = "查询成功,用户名:" + user + " 密码:" + pass; + log.info(resultList.toString()); + message = "查询成功,找到 " + resultList.size() + " 条记录 " + JSONUtil.toJsonStr(resultList); return R.ok(message); default: @@ -370,7 +364,6 @@ public R safe1( stmt.setString(1, username); stmt.setString(2, password); stmt.setString(3, id); - stmt.executeUpdate(); rowsAffected = stmt.executeUpdate(); stmt.close(); @@ -405,7 +398,7 @@ public R safe1( } } - @ApiOperation(value = "安全代码:JdbcTemplate预编译", notes = "JDBCTemplate预编译 此时在常规DML场景有效的防止了SQL注入攻击的发生") + @ApiOperation(value = "安全代码:JdbcTemplate参数绑定", notes = "JdbcTemplate通过占位符和参数绑定分离SQL结构与参数值,可在常规DML场景中有效防止SQL注入") @GetMapping("/safe2") @ApiImplicitParams({ @ApiImplicitParam(name = "type", value = "操作类型", required = true, dataType = "String", paramType = "query", dataTypeClass = String.class), @@ -475,7 +468,7 @@ public R safe2( } } - @ApiOperation(value = "安全代码:自定义黑名单-用户输入过滤", notes = "检测用户输入是否存在敏感字符:'、;、--、+、,、%、=、>、<、*、(、)、and、or、exeinsert、select、delete、update、count、drop、chr、midmaster、truncate、char、declare") + @ApiOperation(value = "辅助方案:自定义黑名单-用户输入过滤", notes = "黑名单只能作为辅助检测或拦截,不应替代参数化查询。遗漏关键字、编码绕过、语法变形都可能导致绕过。") @ApiImplicitParams({ @ApiImplicitParam(name = "type", value = "操作类型", required = true, dataType = "String", paramType = "query", dataTypeClass = String.class), @ApiImplicitParam(name = "id", value = "用户ID", dataType = "String", paramType = "query", dataTypeClass = String.class), @@ -572,21 +565,30 @@ public R safe3( // 对用户输入进行校验后 同样也可以避免SQL注入 后续在MyBatis模块中 将通过validation模块 利用Spring框架提供的注解来对请求参数进行验证,从而确保它们满足特定的条件 @ApiOperation(value = "安全代码:数据类型-用户请求参数校验", notes = "强制类型转换 对用户请求参数进行校验") @ApiImplicitParams({ - @ApiImplicitParam(name = "id", value = "用户ID", dataType = "Integer", paramType = "query", dataTypeClass = Integer.class) // 这里使用了Integer类型 + @ApiImplicitParam(name = "id", value = "用户ID", dataType = "String", paramType = "query", dataTypeClass = String.class) }) @ResponseBody @GetMapping("/safe4") public R safe4( - @ApiParam(name = "id", value = "用户ID") @RequestParam(required = false) Integer id) { + @ApiParam(name = "id", value = "用户ID") @RequestParam(required = false) String id) { String sql = ""; try { + if (id == null || id.trim().isEmpty()) { + return R.error("请输入用户id"); + } + Integer userId; + try { + userId = Integer.valueOf(id); + } catch (NumberFormatException e) { + return R.error("用户ID必须为整数"); + } Class.forName("com.mysql.cj.jdbc.Driver"); Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPass); Statement stmt = conn.createStatement(); String message; - message = checkUserInput.checkUser(id); + message = checkUserInput.checkUser(userId); if (!message.isEmpty()) return R.error(message); - sql = "SELECT * FROM sqli WHERE id = " + id; + sql = "SELECT * FROM sqli WHERE id = " + userId; log.info("当前执行数据查询操作:" + sql); ResultSet rs = stmt.executeQuery(sql); if (!rs.next()) { @@ -607,7 +609,7 @@ public R safe4( } } - @ApiOperation(value = "安全代码:Web安全框架-采用ESAPI过滤", notes = "ESAPI提供了多种输入验证API,提供对XSS攻击和SQL注入攻击等的防护") + @ApiOperation(value = "辅助方案:ESAPI encodeForSQL", notes = "encodeForSQL是历史方案或特定数据库Codec场景下的补充手段,不推荐作为SQL注入的首选修复。优先使用参数化查询。") @ApiImplicitParam(name = "id", value = "用户ID", dataType = "String", paramType = "query", dataTypeClass = String.class) @GetMapping("/safe5") @ResponseBody @@ -618,7 +620,7 @@ public R safe5(@ApiParam(name = "id", value = "用户ID") @RequestParam(required Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPass); Statement stmt = conn.createStatement(); - // 使用了 Oracle 的编解码器 OracleCodec 和 ESAPI 库来对 ID 进行编码,以防止 SQL 注入攻击。 + // encodeForSQL是历史方案或特定数据库Codec场景下的补充手段,优先使用参数化查询。 String sql = "select * from sqli where id = '" + ESAPI.encoder().encodeForSQL(oracleCodec, id) + "'"; // String sql = "select * from sqli where id = '" + id + "'"; log.info("当前执行数据查询操作:" + sql); @@ -648,7 +650,7 @@ public R safe5(@ApiParam(name = "id", value = "用户ID") @RequestParam(required 并且预编译还与 MySQL Connector/J(JDBC驱动)的版本有关, Connector/J 5.0.5之前的版本默认支持预编译, Connector/J 5.0.5之后的版本默认不支持预编译, 所以我们用的Connector/J 5.0.5驱动以后版本的话默认都是没有打开预编译的 (如果需要打开预编译,需要配置 useServerPrepStmts 参数) */ - @ApiOperation(value = "特殊场景:使用prepareStatement时,order by下的sql注入问题", notes = "ORDER BY关键字用于按升序或降序对结果集进行排序。 由于order by后面需要紧跟column_name,而预编译是参数化字符串,而order by后面紧跟字符串就会不支持原有功能 使用默认排序,因此通常防御order by注入需要使用白名单的方式") + @ApiOperation(value = "特殊场景:使用prepareStatement时,order by下的sql注入问题", notes = "占位符只能绑定值,不能绑定列名、表名、关键字、排序方向等SQL结构。ORDER BY动态字段应使用枚举映射或白名单。") @GetMapping("/special1-OrderBy") @ApiImplicitParams({ @ApiImplicitParam(name = "type", value = "操作类型", required = true, dataType = "String", paramType = "query", dataTypeClass = String.class), @@ -688,7 +690,7 @@ public R special1OrderBy( return R.ok(jsonArray.toString()); case "prepareStatement": - // 可以测试下 预编译没有报错 不过插入语句不生效 默认使用主键升序 + // 占位符只能绑定值,不能把用户输入变成列名。这里不会按传入字段排序。 sql = "select * from sqli order by ?"; log.info("当前执行数据排序操作:" + sql + " 参数:" + field); preparedStatement = conn.prepareStatement(sql); @@ -708,11 +710,11 @@ public R special1OrderBy( } return R.ok(jsonArray.toString()); case "writeList": - sql = "SELECT * FROM sqli ORDER BY " + field; if (!checkUserInput.checkSqlWhiteList(field)) { log.error("field字段不合法!field:" + field); return R.error("field字段不合法!"); } + sql = "SELECT * FROM sqli ORDER BY " + field; log.info("当前执行数据排序操作:" + sql + " 参数:" + field); preparedStatement = conn.prepareStatement(sql); rs = preparedStatement.executeQuery(); @@ -831,11 +833,6 @@ public R special3Limit( sql = "SELECT * FROM sqli ORDER BY id DESC LIMIT " + size; log.info("当前执行数据查询操作:" + sql); rs = stmt.executeQuery(sql); - if (!rs.next()) { - stmt.close(); - conn.close(); - return R.error("没有相关用户信息"); - } JSONArray jsonArray = new JSONArray(); while (rs.next()) { String id = rs.getString("id"); @@ -849,12 +846,21 @@ public R special3Limit( } stmt.close(); conn.close(); + if (jsonArray.isEmpty()) { + return R.error("没有相关用户信息"); + } return R.ok(jsonArray.toString()); case "prepareStatement": // 使用预编译 sql = "SELECT * FROM sqli ORDER BY id DESC LIMIT ?"; log.info("执行的sql语句:" + sql); + int limitSize; + try { + limitSize = Integer.parseInt(size); + } catch (NumberFormatException e) { + return R.error("size必须为整数"); + } preparedStatement = conn.prepareStatement(sql); - preparedStatement.setString(1, size); + preparedStatement.setInt(1, limitSize); rs = preparedStatement.executeQuery(); jsonArray = new JSONArray(); @@ -878,4 +884,134 @@ public R special3Limit( } } + @ApiOperation(value = "特殊场景:二次SQL注入", notes = "第一次使用参数化写入恶意数据,第二次从数据库取出该数据并拼接进SQL时触发注入。") + @GetMapping("/special4-SecondOrder") + @ResponseBody + public R special4SecondOrder( + @ApiParam(name = "type", value = "操作类型:store、trigger、safeTrigger", required = true) @RequestParam String type, + @ApiParam(name = "id", value = "用户ID") @RequestParam(required = false) String id, + @ApiParam(name = "username", value = "用户名") @RequestParam(required = false) String username, + @ApiParam(name = "password", value = "密码") @RequestParam(required = false) String password + ) { + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPass)) { + switch (type) { + case "store": + if (username == null || username.trim().isEmpty()) { + return R.error("username不能为空!"); + } + String insertSql = "INSERT INTO sqli (username, password) VALUES (?, ?)"; + log.info("二次注入第一步,参数化写入数据: {}", insertSql); + try (PreparedStatement preparedStatement = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { + preparedStatement.setString(1, username); + preparedStatement.setString(2, password); + int rowsAffected = preparedStatement.executeUpdate(); + String newId = ""; + try (ResultSet generatedKeys = preparedStatement.getGeneratedKeys()) { + if (generatedKeys.next()) { + newId = generatedKeys.getString(1); + } + } + return R.ok("数据写入成功,影响行数:" + rowsAffected + ",新用户ID:" + newId + "。下一步使用该ID触发二次查询。"); + } + case "trigger": + return queryByStoredUsername(conn, id, false); + case "safeTrigger": + return queryByStoredUsername(conn, id, true); + default: + return R.error("type字段有误:传输数据异常,请检查^_^"); + } + } + } catch (Exception e) { + log.error(e.toString()); + return R.error(e.toString()); + } + } + + @ApiOperation(value = "特殊场景:UNION联合查询回显", notes = "通过UNION SELECT拼接额外查询结果,使数据库名、当前用户等信息进入正常查询回显。") + @GetMapping("/special5-Union") + @ResponseBody + public R special5Union( + @ApiParam(name = "type", value = "操作类型:raw、prepareStatement", required = true) @RequestParam String type, + @ApiParam(name = "id", value = "用户ID或UNION Payload") @RequestParam(required = false) String id + ) { + String sql = ""; + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + try (Connection conn = DriverManager.getConnection(dbUrl, dbUser, dbPass)) { + ResultSet rs; + switch (type) { + case "raw": + sql = "SELECT id, username, password FROM sqli WHERE id = " + id; + log.info("当前执行UNION回显查询操作: {}", sql); + try (Statement stmt = conn.createStatement()) { + rs = stmt.executeQuery(sql); + return R.ok(resultSetToJsonArray(rs).toString()); + } + case "prepareStatement": + sql = "SELECT id, username, password FROM sqli WHERE id = ?"; + log.info("当前执行UNION回显安全查询操作: {}", sql); + try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) { + preparedStatement.setString(1, id); + rs = preparedStatement.executeQuery(); + return R.ok(resultSetToJsonArray(rs).toString()); + } + default: + return R.error("type字段有误:传输数据异常,请检查^_^"); + } + } + } catch (Exception e) { + log.error(e.toString()); + return R.error(e.toString()); + } + } + + private R queryByStoredUsername(Connection conn, String id, boolean safe) throws SQLException { + if (id == null || id.trim().isEmpty()) { + return R.error("id不能为空!"); + } + String usernameSql = "SELECT username FROM sqli WHERE id = ?"; + String storedUsername; + try (PreparedStatement preparedStatement = conn.prepareStatement(usernameSql)) { + preparedStatement.setString(1, id); + try (ResultSet rs = preparedStatement.executeQuery()) { + if (!rs.next()) { + return R.error("用户ID不存在!"); + } + storedUsername = rs.getString("username"); + } + } + + if (safe) { + String safeSql = "SELECT id, username, password FROM sqli WHERE username = ?"; + log.info("二次注入安全触发,参数化查询: {} 参数: {}", safeSql, storedUsername); + try (PreparedStatement preparedStatement = conn.prepareStatement(safeSql)) { + preparedStatement.setString(1, storedUsername); + try (ResultSet rs = preparedStatement.executeQuery()) { + return R.ok(resultSetToJsonArray(rs).toString()); + } + } + } + + String vulSql = "SELECT id, username, password FROM sqli WHERE username = '" + storedUsername + "'"; + log.info("二次注入漏洞触发,拼接查询: {}", vulSql); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(vulSql)) { + return R.ok(resultSetToJsonArray(rs).toString()); + } + } + + private JSONArray resultSetToJsonArray(ResultSet rs) throws SQLException { + JSONArray jsonArray = new JSONArray(); + while (rs.next()) { + JSONObject jsonObject = JSONUtil.createObj(); + jsonObject.put("id", rs.getString("id")); + jsonObject.put("username", rs.getString("username")); + jsonObject.put("password", rs.getString("password")); + jsonArray.put(jsonObject); + } + return jsonArray; + } + } diff --git a/src/main/java/top/whgojp/modules/sqli/controller/MyBatisController.java b/src/main/java/top/whgojp/modules/sqli/controller/MyBatisController.java index 0d7160d..5aa155e 100644 --- a/src/main/java/top/whgojp/modules/sqli/controller/MyBatisController.java +++ b/src/main/java/top/whgojp/modules/sqli/controller/MyBatisController.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import top.whgojp.common.utils.CheckUserInput; import top.whgojp.modules.sqli.entity.Sqli; import top.whgojp.modules.sqli.service.SqliService; @@ -34,6 +35,8 @@ public class MyBatisController { @Autowired private SqliService sqliService; + @Autowired + CheckUserInput checkUserInput; Logger log = LoggerFactory.getLogger(JdbcController.class); @GetMapping("") @@ -64,11 +67,17 @@ public R safe1( log.info(message); return R.ok(message); case "delete": - rowsAffected = sqliService.nativeDelete(Integer.valueOf(id)); + if (id == null) { + return R.error("id不能为空!"); + } + rowsAffected = sqliService.nativeDelete(id); message = (rowsAffected > 0) ? "数据删除成功" : "数据删除失败 用户ID:" + id + " 不存在!"; log.info(message); return R.ok(message); case "update": + if (id == null) { + return R.error("id不能为空!"); + } rowsAffected = sqliService.nativeUpdate(new Sqli(id, username, password)); message = (rowsAffected > 0) ? "数据更新成功" : "数据更新失败 用户ID不存在!"; log.info(message); @@ -121,18 +130,30 @@ public R safe2( return R.ok(message); case "delete": //这里删除数据使用自定义代码 - rowsAffected = sqliService.customDelete(Integer.valueOf(id)); + if (id == null) { + return R.error("id不能为空!"); + } + rowsAffected = sqliService.customDelete(id); message = (rowsAffected > 0) ? "数据删除成功" : "数据删除失败 用户ID:" + id + " 不存在!"; log.info(message); return R.ok(message); case "update": //使用MyBatis注解 - rowsAffected = sqliService.customUpdate(new Sqli(Integer.valueOf(id), username, password)); + if (id == null) { + return R.error("id不能为空!"); + } + rowsAffected = sqliService.customUpdate(new Sqli(id, username, password)); message = (rowsAffected > 0) ? "数据更新成功" : "数据更新失败 用户ID不存在!"; log.info(message); return R.ok(message); case "select": - final Sqli user = sqliService.customSelect(Integer.valueOf(id)); + if (id == null) { + return R.error("id不能为空!"); + } + final Sqli user = sqliService.customSelect(id); + if (user == null) { + return R.ok("用户ID不存在!"); + } message = "查询成功,用户名:" + user.getUsername() + " 密码:" + user.getPassword(); return R.ok(message); default: @@ -167,6 +188,10 @@ public R special1OrderBy( sqlis = sqliService.orderByPrepareStatement(field); break; case "writeList": + if (!checkUserInput.checkSqlWhiteList(field)) { + log.error("field字段不合法!field:" + field); + return R.error("field字段不合法!"); + } sqlis = sqliService.orderByWriteList(field); break; default: @@ -246,8 +271,11 @@ public R special3In( sqlis = sqliService.inPrepareStatement(scope); break; case "Foreach": - - sqlis = sqliService.inSafeForeach(parseInputToList(scope)); + List idList = parseInputToList(scope); + if (idList.isEmpty()) { + return R.error("scope中没有合法整数ID!"); + } + sqlis = sqliService.inSafeForeach(idList); break; default: return R.error("type字段有误:传输数据异常,请检查^_^"); @@ -262,6 +290,9 @@ public R special3In( public static List parseInputToList(String input) { List resultList = new ArrayList<>(); + if (input == null || input.trim().isEmpty()) { + return resultList; + } // 切割字符串并转换为整数 String[] parts = input.split(","); diff --git a/src/main/resources/mapper/SqliMapper.xml b/src/main/resources/mapper/SqliMapper.xml index b27d600..a977910 100644 --- a/src/main/resources/mapper/SqliMapper.xml +++ b/src/main/resources/mapper/SqliMapper.xml @@ -14,8 +14,8 @@ id,username,password insert into sqli (id,username,password) values (#{id,jdbcType=INTEGER},#{username,jdbcType=VARCHAR},#{password,jdbcType=VARCHAR}) @@ -33,7 +33,7 @@ - + - + - + \n" + " SELECT * FROM sqli\n" + " \n" + " ORDER BY ${field}\n" + " \n" + "\n" + - "\n" + + "\n" + "\n" + - "\n" + + "\n" + " -

    预编译下 ordery by排序失效 使用默认排序

    +

    占位符只能绑定值,不能绑定列名,ORDER BY ? 不会按传入字段排序

    +
    +
    +
    +

    特殊场景:二次SQL注入

    +
    +
      +
    • 写入恶意数据
    • +
    • 二次查询触发
    • +
    • 安全查询对照
    • +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    tips
    +
    +
      二次SQL注入的关键是“第一次入库不触发,第二次使用已存储数据拼接SQL时触发”。
    +  数据库中的历史数据仍然是不可信输入;只要进入SQL语句,就必须重新按参数化或白名单规则处理。
    +
    +
    +
    +
    测试结果
    +
    +
    
    +                                        
    +
    +
    +
    +
    +
    + +
    +

    漏洞代码

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    特殊场景:UNION联合查询回显

    +
    +
      +
    • 拼接注入
    • +
    • 参数绑定
    • +
    +
    +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    tips
    +
    +
      UNION回显要求前后SELECT列数一致、类型兼容。本场景原查询固定返回 id、username、password 三列。
    +  参数绑定会把整段UNION Payload当作普通参数值,不会改变原SQL结构。
    +
    +
    +
    +
    测试结果
    +
    +
    
    +                                        
    +
    +
    +
    +
    +
    + +
    +

    漏洞代码

    +
    +
    +
    +
    +
    + +
    +
    @@ -320,6 +514,15 @@

    漏洞代码

    common.formListenFun("special3-Limit-prepareStatement", "prepareStatement", "/sqli/jdbc/special3-Limit", "special3-Limit-result","get"); common.selectListenFun("special3-Limit-raw-select", "special3-Limit-raw-size"); + common.formListenFun("special4-SecondOrder-store", "store", "/sqli/jdbc/special4-SecondOrder", "special4-SecondOrder-result","get"); + common.formListenFun("special4-SecondOrder-trigger", "trigger", "/sqli/jdbc/special4-SecondOrder", "special4-SecondOrder-result","get"); + common.formListenFun("special4-SecondOrder-safeTrigger", "safeTrigger", "/sqli/jdbc/special4-SecondOrder", "special4-SecondOrder-result","get"); + common.selectListenFun("special4-SecondOrder-store-select", "special4-SecondOrder-store-username"); + + common.formListenFun("special5-Union-raw", "raw", "/sqli/jdbc/special5-Union", "special5-Union-result","get"); + common.formListenFun("special5-Union-prepareStatement", "prepareStatement", "/sqli/jdbc/special5-Union", "special5-Union-result","get"); + common.selectListenFun("special5-Union-raw-select", "special5-Union-raw-id"); + var cmConfig = { lineNumbers: true, lineWrapping: false, @@ -340,6 +543,12 @@

    漏洞代码

    CodeMirror(document.getElementById("special3Limit"), Object.assign({}, cmConfig, { value: special3Limit })); + CodeMirror(document.getElementById("special4SecondOrder"), Object.assign({}, cmConfig, { + value: special4SecondOrder + })); + CodeMirror(document.getElementById("special5Union"), Object.assign({}, cmConfig, { + value: special5Union + })); }); diff --git a/src/main/resources/templates/vul/sqli/jdbcVul.html b/src/main/resources/templates/vul/sqli/jdbcVul.html index 9eed1bb..23f0491 100644 --- a/src/main/resources/templates/vul/sqli/jdbcVul.html +++ b/src/main/resources/templates/vul/sqli/jdbcVul.html @@ -12,7 +12,9 @@

    -

      SQL注入是一种常见的Web应用程序安全漏洞,攻击者利用这种漏洞通过在输入字段中插入恶意SQL语句来执行非授权的数据库操作。这可能导致数据泄露、数据损坏或者完整性受损
    +
      SQL注入的本质是:不可信输入被拼接进SQL语法结构,导致攻击者改变原SQL语义,进而造成越权查询、数据泄露、数据篡改或破坏。
    +
      注入点不只来自输入框,也可能来自URL参数、Header、JSON字段、排序字段、分页参数以及ORM查询语句等位置。
    +
      修复优先级:参数值使用PreparedStatement/JdbcTemplate参数绑定;列名、排序方向、表名等SQL结构必须使用枚举或白名单映射。
      JDBC(Java Database Connectivity)是一套用于在Java程序中操作关系型数据库的API。它提供了一种标准的Java方法来连接和执行SQL查询,从而实现与数据库的交互

    @@ -171,7 +173,7 @@

    缺陷代码

    -

    漏洞场景:JDBC-预编译拼接

    +

    漏洞场景:JDBC-伪预编译拼接

    • 增加
    • @@ -442,15 +444,16 @@

      缺陷代码

      document.body.appendChild(link); link.click(); document.body.removeChild(link); + layer.msg("正在下载流量包:" + selectedValue + ".pcapng"); } } form.on('select(sqliFlowSelect)', function (data) { let selectedValue = data.value; let fileMap = { - "upload_html": "/other/datapackage/sqli/sqli_time.pcapng", - "upload_svg": "/other/datapackage/sqli/sqli_boolean.pcapng", - "upload_pdf": "/other/datapackage/sqli/sqli_error.pcapng", - "upload_xml": "/other/datapackage/sqli/sqli_xpath.pcapng" + "sqli_time": "/other/datapackage/sqli/sqli_time.pcapng", + "sqli_boolean": "/other/datapackage/sqli/sqli_boolean.pcapng", + "sqli_error": "/other/datapackage/sqli/sqli_error.pcapng", + "sqli_xpath": "/other/datapackage/sqli/sqli_xpath.pcapng" }; triggerDownload(fileMap, selectedValue); }); diff --git a/src/main/resources/templates/vul/sqli/jpa.html b/src/main/resources/templates/vul/sqli/jpa.html index 9d95249..5cf1d20 100644 --- a/src/main/resources/templates/vul/sqli/jpa.html +++ b/src/main/resources/templates/vul/sqli/jpa.html @@ -11,7 +11,9 @@

      -

         JPA(Java Persistence API)是Java EE标准的ORM规范,它提供了一种优雅的方式来操作数据库。尽管JPA提供了许多安全特性,但不当使用仍可能导致SQL注入漏洞。
      +
        SQL注入的本质是:不可信输入被拼接进SQL语法结构,导致攻击者改变原SQL语义。JPA中的JPQL、nativeQuery和动态排序字段同样需要关注。
      +
        JPA(Java Persistence API)是Java EE标准的ORM规范,它提供了一种优雅的方式来操作数据库。尽管JPA提供了参数绑定等安全特性,但不当使用仍可能导致SQL注入漏洞。
      +
        修复优先级:查询参数使用命名参数绑定;动态字段、排序方向等SQL结构使用Criteria API配合白名单映射。

      @@ -66,54 +68,51 @@

      缺陷代码

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
    +
    +
    +

    漏洞场景:JPA-动态排序注入

    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    测试结果
    +
    +
    
    +                                        
    +
    +
    +
    +
    +
    - - - - - - - - - +
    +

    缺陷代码

    +
    +
    +
    +
    +
    +
    +
    @@ -160,6 +159,54 @@

    安全代码

    +
    +
    +
    + +
    +
    +
    +

    安全场景:JPA-动态排序白名单

    +
    +
    +
    +
    +
    +
    + +

    仅允许 id、username、password

    +
    +
    + +
    +
    +
    +
    +
    +
    +
    测试结果
    +
    +
    
    +                                        
    +
    +
    +
    +
    +
    + +
    +

    安全代码

    +
    +
    +
    +
    +

@@ -180,6 +227,7 @@

安全代码

common.formListenFun("vul1-jpa-jpql-select", "select", "/sqli/jpa/vul1", "vul1-jpa-jpql-result","get"); common.formListenFun("vul3-jpa-sort-select", "select", "/sqli/jpa/vul2", "vul3-jpa-sort-result","get"); common.formListenFun("safe1-jpa-param-select", "select", "/sqli/jpa/safe", "safe1-jpa-param-result","get"); + common.formListenFun("safe2-jpa-sort-select", "select", "/sqli/jpa/safe-order", "safe2-jpa-sort-result","get"); var cmConfig = { lineNumbers: true, @@ -211,6 +259,9 @@

安全代码

CodeMirror(document.getElementById("safeJpaParam"), Object.assign({}, cmConfigSafe, { value: safeJpaParam })); + CodeMirror(document.getElementById("safeJpaSort"), Object.assign({}, cmConfigSafe, { + value: safeJpaSort + })); }); diff --git a/src/main/resources/templates/vul/sqli/mybatis.html b/src/main/resources/templates/vul/sqli/mybatis.html index 19bbbce..b41bc8a 100644 --- a/src/main/resources/templates/vul/sqli/mybatis.html +++ b/src/main/resources/templates/vul/sqli/mybatis.html @@ -12,11 +12,12 @@

-

  MyBatis是一款半ORM(对象关系映射)框架,它允许开发者通过简单的XML或注解配置,将原生SQL、存储过程以及高级映射映射到Java对象(POJO)。MyBatis的设计目标是简化数据库操作,避免了几乎所有的JDBC代码和手动设置参数,使得数据持久化操作更加直观和便捷
+
  SQL注入的本质是:不可信输入被拼接进SQL语法结构,导致攻击者改变原SQL语义。MyBatis中的XML、注解SQL和动态SQL片段都需要关注这一点。
+
  MyBatis是一款半ORM(对象关系映射)框架,它允许开发者通过简单的XML或注解配置,将原生SQL、存储过程以及高级映射映射到Java对象(POJO)。

-

   #{}: 用于参数的预处理,能够安全地传递参数,并支持JDBC类型的指定
-   ${}: 用于静态SQL片段的替换,比如表名、列名等动态确定的部分
+
  #{}:用于绑定“值”,底层走PreparedStatement参数化,适合普通查询条件、LIKE值、IN中的单个元素等。
+  ${}:用于SQL片段替换,只能用于受控的表名、列名、排序方向等SQL结构;用户输入直接进入${}就是高危点。

@@ -25,7 +26,7 @@
-

安全代码:原生代码

+

安全场景:MyBatis内置方法

@@ -51,6 +51,46 @@

缺陷代码

+ +
+
+
+

安全场景:图片尺寸限制

+
+
+
+
+

服务端限制宽高、总像素和请求频率,避免用户输入直接决定资源消耗。

+ + + + + + +
+
+ +
+
+
tips
+
+
图片、报表、压缩、加密、搜索等高成本功能都应设置输入上限、超时、并发限制和异步队列。
+
+
+
+
+
+
+ +
+

安全代码

+
+
+
+
+
+
+
@@ -140,6 +180,7 @@

缺陷代码

}, error: function () { console.log("上传文件失败"); + $(config.resultElem).html("上传文件失败,请检查文件类型和大小"); } }); } @@ -167,6 +208,7 @@

缺陷代码

// 初始化代码编辑器 CodeMirror(document.getElementById("vulDos"), Object.assign({}, cmConfig, { value: vulDos })); + CodeMirror(document.getElementById("safeDos"), Object.assign({}, cmConfig, { value: safeDos })); CodeMirror(document.getElementById("vul2Dos"), Object.assign({}, cmConfig, { value: vul2Dos })); // hover 效果 diff --git a/src/main/resources/templates/vul/other/onlyForGoogle.html b/src/main/resources/templates/vul/other/onlyForGoogle.html new file mode 100644 index 0000000..39ef0dc --- /dev/null +++ b/src/main/resources/templates/vul/other/onlyForGoogle.html @@ -0,0 +1,12 @@ + + + + + XFF访问控制演示 + + +

only For 8.8.8.8

+

+

+ + diff --git a/src/main/resources/templates/vul/other/url-redirect-vul.html b/src/main/resources/templates/vul/other/url-redirect-vul.html index 93726f4..6882df7 100644 --- a/src/main/resources/templates/vul/other/url-redirect-vul.html +++ b/src/main/resources/templates/vul/other/url-redirect-vul.html @@ -50,7 +50,7 @@

漏洞场景:基于Spring MVC的重定

@@ -70,7 +70,7 @@

漏洞场景:基于Spring MVC的重定
@@ -139,7 +139,7 @@

漏洞场景:基于Servlet标准的重

@@ -158,7 +158,7 @@

漏洞场景:基于Servlet标准的重
@@ -224,7 +224,7 @@

漏洞场景:基于Spring注解和状态 @@ -243,7 +243,7 @@

漏洞场景:基于Spring注解和状态
diff --git a/src/main/resources/templates/vul/other/xff-forgery.html b/src/main/resources/templates/vul/other/xff-forgery.html index 5eb9bbc..1f9bbb9 100644 --- a/src/main/resources/templates/vul/other/xff-forgery.html +++ b/src/main/resources/templates/vul/other/xff-forgery.html @@ -12,7 +12,7 @@

-

  XFF伪造:通过篡改HTTP头字段(X-Forwarded-For)隐瞒真实IP地址,危害包括隐藏身份、绕过访问控制和污染日志
+
  XFF伪造:服务端在未确认请求来自可信代理时直接信任X-Forwarded-For,攻击者即可伪造客户端IP,绕过基于IP的访问控制或污染审计日志。

@@ -128,9 +128,9 @@

安全场景:可信源IP过滤
tips
安全编码规范:
-    1. 配置可信IP白名单,仅允许可信代理设置或修改该头信息。
-    2. 限制XFF头格式(合法IPv4/IPv6地址、设定最大长度),防范恶意格式注入与缓冲区溢出攻击。
-    3. 在身份校验和授权中,结合会话标识符、用户令牌等多因素验证,防止伪造导致的认证绕过。
+ 1. 只在 request.getRemoteAddr() 命中可信代理白名单时解析 X-Forwarded-For。 + 2. 取第一个客户端IP前应校验格式、长度和私有地址策略,异常时回退到真实连接来源。 + 3. 不要把客户端IP作为唯一认证依据,关键授权应结合登录态、角色、资源归属和审计。
@@ -164,7 +164,7 @@

缺陷代码

var xff = $("#xff").is(':checked'); $.ajax({ - url: "[[@{/other/xff/vul2}]]", + url: "/other/xff/vul2", type: "get", headers: { 'X-Forwarded-For': "8.8.8.8", @@ -175,7 +175,11 @@

缺陷代码

document.write(result) }, error: function () { - alert("请求发送失败!"); + if (layui.layer) { + layui.layer.msg("请求发送失败!", {icon: 2, offset: '10px'}); + } else { + alert("请求发送失败!"); + } } }); }); @@ -196,7 +200,11 @@

缺陷代码

document.write(result) }, error: function () { - alert("请求发送失败!"); + if (layui.layer) { + layui.layer.msg("请求发送失败!", {icon: 2, offset: '10px'}); + } else { + alert("请求发送失败!"); + } } }); }); diff --git a/src/main/resources/templates/vul/other/xpath.html b/src/main/resources/templates/vul/other/xpath.html index f70c4b3..a542458 100644 --- a/src/main/resources/templates/vul/other/xpath.html +++ b/src/main/resources/templates/vul/other/xpath.html @@ -16,8 +16,8 @@

-

  XPath是一种用于查询和操作XML文档的语言,类似于SQL在数据库中的作用,广泛应用于XML解析和数据提取
-
  XPath注入类似于SQL注入,攻击者通过构造特殊的输入操控未过滤的XPath查询,从而访问或篡改XML数据,甚至绕过验证获取敏感信息
+
  XPath是一种用于查询XML文档的表达式语言,常见于配置、身份数据、SOAP/XML接口或本地XML文件查询。
+
  XPath注入发生在用户输入被拼接进XPath表达式时,攻击者可改变查询条件,绕过认证或读取非预期节点。

@@ -58,7 +58,7 @@

漏洞场景:原生漏洞场景 tips
-
类似于SQL注入,可使用"万能密码"
+
类似于SQL注入,可使用万能条件让谓词恒真,例如:' or '1'='1
@@ -87,7 +87,7 @@

缺陷代码

-

安全场景:StringEscapeUtils实体转义

+

安全场景:XPath变量绑定

@@ -119,7 +119,7 @@

安全场景:StringEscapeUtils实体
tips
-
  StringEscapeUtils.escapeXml10()是Apache Commons Lang方法,用于将 &、<、>、' 和 " 转换为XML 实体,确保字符串安全用于XML文档
+
  不把用户输入拼接进XPath表达式,而是使用变量绑定或业务层精确匹配;XML实体转义适合写入XML文本,不能等同于XPath注入修复。

From 421e30403667f7af798305277e777eef36d115b8 Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Wed, 27 May 2026 07:46:05 +0800 Subject: [PATCH 12/19] Optimize sensitive information leak module --- docs/instructions.md | 63 +++++++++++++++++++ .../infoleak/controller/CeshiController.java | 36 +++++++++++ src/main/resources/static/js/staticcode.js | 11 ++-- .../templates/vul/infoleak/backup.html | 6 +- .../templates/vul/infoleak/ceshi.html | 4 +- .../templates/vul/infoleak/dirTraversal.html | 8 +-- .../templates/vul/infoleak/jsLeak.html | 7 +-- .../templates/vul/infoleak/ping.html | 34 +++++++--- 8 files changed, 145 insertions(+), 24 deletions(-) diff --git a/docs/instructions.md b/docs/instructions.md index 6acd6e5..64e3057 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -670,4 +670,67 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 | 安全拦截 | `POST /other/xpath/safe` | `username=admin&password=' or '1'='1` | 返回认证失败 | | 安全正常认证 | `POST /other/xpath/safe` | `username=admin&password=password` | 返回认证通过 | +## 敏感信息泄漏 + +当前覆盖 JS 前端泄漏、目录遍历、测试页面遗留和备份文件泄漏四类场景。该模块的重点不是单个危险 API,而是“本不该暴露给用户的内容被放在了可访问位置”:前端代码、构建产物、目录列表、测试工具、源码包、日志和临时文件都可能成为攻击入口。 + +敏感信息泄漏的修复核心是最小暴露面和发布前检查:密钥、认证逻辑、内部接口和敏感配置不能进入前端;静态目录不放备份、日志和测试文件;目录列表和测试页面默认关闭;必须保留的诊断入口应做强鉴权、白名单、审计、超时和输入限制;已泄漏的密钥、Token、Cookie 和密码应立即轮换。 + +已覆盖类型 + +| 分类 | 已有场景 | 结论 | +| --- | --- | --- | +| JS 泄漏 | 前端硬编码账号密码、Webpack 打包泄漏云密钥 | 覆盖前端源码和构建产物中的常见敏感信息泄漏 | +| 目录遍历 | 目录列表可控、黑名单过滤、根目录限制 | 覆盖目录浏览功能导致的文件名、路径和资源发现风险 | +| 测试页面 | 遗留 Ping 页面、命令拼接、安全 Ping 对照 | 覆盖测试入口暴露和输入处理不当导致的高风险链路 | +| 备份文件 | Web 源码压缩包、日志文件 | 覆盖源码、配置、SQL、Session 和调试日志泄漏 | + +后续如需增强,可补充“.git/.svn 泄漏”“SourceMap 泄漏”“Spring Boot Actuator env/configprops 泄漏”“Swagger/OpenAPI 未授权文档”“Druid/Spring Boot Admin 未授权”“错误堆栈泄漏”“云元数据/临时凭证泄漏”“配置中心泄漏”等场景。当前 SpringBoot 专题里已有 Swagger、Actuator、Druid 场景,敏感信息泄漏模块可以保留为通用信息暴露主线,避免和专题模块重复过多。 + +### JS 泄漏测试 + +页面:`/infoLeak/js` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /infoLeak/js` | 已登录会话 | 页面正常打开,展示前端硬编码和 Webpack 泄漏场景 | +| 前端硬编码页面 | `GET /infoLeak/js/hard-coding` | 无 | 返回登录页面,页面源码中可见硬编码账号密码 | +| 前端硬编码登录 | 浏览器提交 `/infoLeak/js/hard-coding` | `superadmin` / `Admin@1024.com` | 跳转到 `/infoLeak/js/loginSuccess` | +| Webpack 泄漏 JS | `GET /other/infoleak/chunk-0226s3f2.57e3ed6f.js` | 无 | 返回 JS 文件,内容包含 `SecretId`、`SecretKey`、Bucket 等敏感配置 | + +### 目录遍历测试 + +页面:`/infoLeak/dirTraversal` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /infoLeak/dirTraversal` | 已登录会话 | 页面正常打开,展示目录遍历漏洞和两种安全写法 | +| 目录列表漏洞 | `GET /infoLeak/dirTraversal/vul?dir=/` | 根目录参数 | 返回静态目录列表 | +| 目录穿越尝试 | `GET /infoLeak/dirTraversal/vul?dir=../` | `../` | 可能列出静态目录之外的上级目录内容 | +| 黑名单拦截 | `GET /infoLeak/dirTraversal/safe1?dir=../` | `../` | 返回“非法字符!” | +| 根目录限制 | `GET /infoLeak/dirTraversal/safe2?dir=../` | `../` | 返回 `Directory not found or access denied.` | +| 安全目录访问 | `GET /infoLeak/dirTraversal/safe2?dir=/` | 根目录参数 | 只返回允许根目录内的文件列表 | + +### 测试页面测试 + +页面:`/infoLeak/ceShiPage` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /infoLeak/ceShiPage` | 已登录会话 | 页面正常打开,展示遗留 Ping 测试入口 | +| Ping 页面 | `GET /infoLeak/ceShiPage/pingPage` | 无 | 页面正常打开,展示漏洞 Ping 和安全 Ping 表单 | +| 命令拼接漏洞 | `GET /infoLeak/ceShiPage/ping?ip=127.0.0.1%20%26%20whoami` | `127.0.0.1 & whoami` | 返回 ping 输出并可能追加当前进程用户,说明 shell 元字符生效 | +| 安全 Ping 拦截 | `GET /infoLeak/ceShiPage/safePing?ip=127.0.0.1%20%26%20whoami` | 含 `&` 的输入 | 返回“非法目标地址” | +| 安全 Ping 正常 | `GET /infoLeak/ceShiPage/safePing?ip=127.0.0.1` | 合法地址 | 返回 ping 输出或系统 ping 执行结果 | + +### 备份文件测试 + +页面:`/infoLeak/backUp` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /infoLeak/backUp` | 已登录会话 | 页面正常打开,展示源码备份和日志泄漏场景 | +| 源码备份下载 | `GET /other/infoleak/www.zip` | 无 | 返回可下载压缩包,说明源码备份文件暴露在 Web 目录 | +| 日志文件访问 | `GET /other/infoleak/JavaSecLab_logs.txt` | 无 | 返回日志内容,包含 SQL、账号、SessionId、验证码等敏感信息 | + # Java 专题 diff --git a/src/main/java/top/whgojp/modules/infoleak/controller/CeshiController.java b/src/main/java/top/whgojp/modules/infoleak/controller/CeshiController.java index 214c0a8..2b03f8c 100644 --- a/src/main/java/top/whgojp/modules/infoleak/controller/CeshiController.java +++ b/src/main/java/top/whgojp/modules/infoleak/controller/CeshiController.java @@ -11,6 +11,8 @@ import java.io.BufferedReader; import java.io.InputStreamReader; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; /** * @description 敏感信息泄漏-测试页面 @@ -24,6 +26,8 @@ @CrossOrigin(origins = "*") @RequestMapping("/infoLeak/ceShiPage") public class CeshiController { + private static final Pattern SAFE_HOST_PATTERN = Pattern.compile("^[A-Za-z0-9.-]{1,253}$"); + @RequestMapping("") public String CeShi() { return "vul/infoleak/ceshi"; @@ -58,4 +62,36 @@ public String ping(@RequestParam(name = "ip", required = false) String ip, Model return "vul/infoleak/ping"; } + @GetMapping("/safePing") + public String safePing(@RequestParam(name = "ip", required = false) String ip, Model model) { + String result = ""; + if (ip != null && !ip.isEmpty()) { + if (!SAFE_HOST_PATTERN.matcher(ip).matches() || ip.contains("..")) { + result = "非法目标地址"; + } else { + try { + Process process = new ProcessBuilder("ping", "-c", "4", ip).start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + result = "Ping执行超时"; + } else { + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + StringBuilder output = new StringBuilder(); + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + reader.close(); + result = output.toString(); + } + } catch (Exception e) { + result = "Error: " + e.getMessage(); + } + } + } + model.addAttribute("safeResult", result); + return "vul/infoleak/ping"; + } + } diff --git a/src/main/resources/static/js/staticcode.js b/src/main/resources/static/js/staticcode.js index b3087d6..dfd51db 100755 --- a/src/main/resources/static/js/staticcode.js +++ b/src/main/resources/static/js/staticcode.js @@ -2189,15 +2189,18 @@ const safe1ListDirectory = 'public String safe1(String dir) {\n' + '}' const safe2ListDirectory = "public String safe2(String dir) {\n" + - " String staticFolderPath = sysConstant.getStaticFolder();\n" + - " File baseDir = new File(staticFolderPath);\n" + - " File requestedDir = new File(baseDir, dir);\n" + + " File baseDir = resolveStaticBaseDir();\n" + + " String relativeDir = normalizeRelativeDir(dir);\n" + "\n" + " // 检查请求的目录是否在规定目录内\n" + "try {\n" + - " if (!requestedDir.getCanonicalPath().startsWith(baseDir.getCanonicalPath()) || !requestedDir.isDirectory()) {\n" + + " Path basePath = baseDir.getCanonicalFile().toPath();\n" + + " File requestedDir = new File(baseDir, relativeDir);\n" + + " Path requestedPath = requestedDir.getCanonicalFile().toPath();\n" + + " if (!requestedPath.startsWith(basePath) || !requestedDir.isDirectory()) {\n" + " return \"Directory not found or access denied.\";\n" + " }\n" + + " return renderDirectoryListing(dir, requestedDir, true);\n" + "} catch (IOException e) {\n" + " return \"Error resolving directory path.\";\n" + "}\n" + diff --git a/src/main/resources/templates/vul/infoleak/backup.html b/src/main/resources/templates/vul/infoleak/backup.html index 58935e8..6101f89 100644 --- a/src/main/resources/templates/vul/infoleak/backup.html +++ b/src/main/resources/templates/vul/infoleak/backup.html @@ -12,7 +12,7 @@

-

  备份文件:Web源码泄漏、日志泄漏
+
  备份文件泄漏通常来自源码包、数据库备份、日志、临时压缩包或编辑器备份文件被放入 Web 可访问目录。攻击者可通过常见文件名、目录索引或搜索引擎发现这些文件,进一步获取源码、配置、凭证、Session、SQL日志和内部路径。

@@ -40,7 +40,7 @@

漏洞场景:Web源码泄漏

tips
-
  由于开发/运营人员疏忽,将源码的压缩包(如.zip、.tar.gz、.bak)放置在Web目录下,未做访问限制
+
源码备份常包含 pom.xml、application.yml、SQL脚本、密钥配置和业务代码。生产环境应禁止把备份文件放入静态目录,并通过部署前扫描拦截 .zip、.tar.gz、.bak、.old、.sql 等高风险文件。
@@ -81,7 +81,7 @@

漏洞场景:日志泄漏

tips
-
  在开发过程中,为了调试便捷,开发者常会输出一些敏感信息到日志中,例如登录信息(Session、Cookie、用户名、密码)以及SQL执行记录。这些日志内容通常会被定期打包备份,如果备份日志未妥善管理或被泄漏,攻击者可能获取其中的敏感信息,利用这些信息实施攻击。尤其是在登录时启用“记住我”功能的情况下,Cookie/Session通常会具有较长的有效期(如两周),进一步增加了被利用的风险,最终可能导致凭证劫持
+
日志中不应输出明文密码、Token、Cookie、验证码、完整SQL参数或身份证件等敏感数据。生产日志应脱敏、分级授权、设置保留周期,并禁止通过静态目录直接访问。
diff --git a/src/main/resources/templates/vul/infoleak/ceshi.html b/src/main/resources/templates/vul/infoleak/ceshi.html index 258ba4c..58d8426 100644 --- a/src/main/resources/templates/vul/infoleak/ceshi.html +++ b/src/main/resources/templates/vul/infoleak/ceshi.html @@ -12,7 +12,7 @@

-

  测试页面泄漏:开发者有意或无意遗留下的测试页面泄漏,可能导致敏感信息暴露、安全漏洞被利用、权限提升、系统稳定性受影响以及成为网络攻击跳板,从而严重威胁系统的安全性和稳定性
+
  测试页面泄漏:开发、联调或运维排障页面被部署到生产环境后,可能暴露内部工具、调试参数、环境信息或高危操作入口。此类页面常缺少鉴权、审计和输入限制,容易演变为命令执行、SSRF、文件读取等更高风险漏洞。

@@ -39,7 +39,7 @@

漏洞场景:网络连通性测试
tips
-
   在路由器、交换机等设备中,通常会提供Ping测试页面用于网络连通性测试、故障排查以及网络性能监控。然而,如果在开发Ping测试功能时未进行安全处理,可能会引发命令注入漏洞
+
Ping 页面用于演示测试入口遗留风险。漏洞场景会把参数拼接进 shell 命令;安全场景使用参数白名单和 ProcessBuilder 参数数组,避免 shell 元字符生效。

diff --git a/src/main/resources/templates/vul/infoleak/dirTraversal.html b/src/main/resources/templates/vul/infoleak/dirTraversal.html index 7ebe268..fbeae6c 100644 --- a/src/main/resources/templates/vul/infoleak/dirTraversal.html +++ b/src/main/resources/templates/vul/infoleak/dirTraversal.html @@ -12,7 +12,7 @@

-

  目录遍历漏洞的原因是Web应用在处理用户输入的文件路径时缺乏适当的验证和规范化,导致攻击者可以通过输入特殊字符(如../)访问到应用程序根目录之外的文件。其危害包括泄露敏感信息(如配置文件、密码文件)、读取未授权文件、执行任意代码等,严重威胁系统安全和数据隐私
+
  目录遍历泄漏发生在目录列表、文件浏览、静态资源代理等功能中。应用把用户可控路径拼接到服务端目录后,如果没有标准化路径并校验目录边界,攻击者可通过 ../ 或编码变体浏览非预期目录,进而发现配置、备份、日志或源码文件。

@@ -46,7 +46,7 @@

漏洞场景:原生漏洞场景
tips
-
/listdir?dir=/,可以尝试插入../从而遍历任意目录
+
可尝试 /infoLeak/dirTraversal/vul?dir=../ 观察是否能跳出预期静态目录。
@@ -121,7 +121,7 @@

安全场景:限制遍历目录
-

检查请求的目录是否在规定的跟目录内,可以有效防止目录遍历攻击

+

检查请求的目录是否在规定的根目录内,可以有效防止目录遍历攻击

diff --git a/src/main/resources/templates/vul/infoleak/jsLeak.html b/src/main/resources/templates/vul/infoleak/jsLeak.html index 65cbcf7..9148886 100644 --- a/src/main/resources/templates/vul/infoleak/jsLeak.html +++ b/src/main/resources/templates/vul/infoleak/jsLeak.html @@ -12,7 +12,7 @@

-

  由于开发人员的疏忽,将配置信息和API密钥硬编码到JavaScript文件中,导致敏感信息泄漏,可能引发数据被恶意获取、未经授权的访问、服务滥用等严重安全问题,危害系统和用户数据的安全性
+
  JavaScript文件属于客户端可见资源,任何写入前端源码、打包产物、SourceMap或注释中的账号、密钥、内网地址、接口路径和调试信息都应视为已公开。修复时应把密钥和鉴权逻辑放回服务端,前端只持有短期、最小权限、可吊销的公开配置。

@@ -39,7 +39,7 @@

漏洞场景:登录通过前端校验(硬
tips
-
  项目短期内交付,开发者图方便可能会将登录的账号密码直接硬编码在前端
+
前端校验只能提升交互体验,不能承担认证职责。账号密码、角色判断、接口权限必须在服务端完成。

@@ -79,8 +79,7 @@

漏洞场景:Webpack打包导致云密钥
tips
-
  开发在使用webpack工具打包前端项目时 使用dev开发环境,带出配置信息(前后端分离流行后经常存在的疏忽点)
-  真实漏洞场景下,可以在前端看到webapck目录,以及对应源代码……
+
构建产物中常见泄漏包括云AK/SK、Bucket、内部接口、SourceMap、调试开关和测试账号。发布前应进行密钥扫描,禁用生产SourceMap并轮换已暴露密钥。
diff --git a/src/main/resources/templates/vul/infoleak/ping.html b/src/main/resources/templates/vul/infoleak/ping.html index bd51e27..e8f814d 100644 --- a/src/main/resources/templates/vul/infoleak/ping.html +++ b/src/main/resources/templates/vul/infoleak/ping.html @@ -2,14 +2,34 @@ 网络连通性测试 + -

Ping 测试

- - - - - -

+

Ping 测试页面

+
+

漏洞场景:命令拼接

+
+ + + +
+

+
+ +
+

安全场景:参数白名单 + ProcessBuilder参数数组

+
+ + + +
+

+
From d003e52ccc3eeecf12b8ce1eee2e393041d79505 Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Wed, 27 May 2026 09:44:00 +0800 Subject: [PATCH 13/19] Add concurrent safety scenario --- docs/instructions.md | 16 +- .../controller/ConcurrentController.java | 85 +++++++ src/main/resources/static/api/init.json | 7 +- src/main/resources/static/js/staticcode.js | 30 +++ .../vul/logic/concurrent/concurrent.html | 228 ++++++++++++++++++ 5 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 src/main/java/top/whgojp/modules/logic/concurrent/controller/ConcurrentController.java create mode 100644 src/main/resources/templates/vul/logic/concurrent/concurrent.html diff --git a/docs/instructions.md b/docs/instructions.md index 64e3057..485c951 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -526,7 +526,7 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 ## 逻辑漏洞 -当前覆盖越权访问、验证码安全和支付业务逻辑三条主线:越权包含水平越权和垂直越权;验证码包含图形验证码复用、万能验证码、弱图形验证码、短信验证码回显和参数绕过;支付包含金额篡改、订单重放、并发竞态、流程绕过、整数溢出和浮点数精度问题。 +当前覆盖越权访问、验证码安全、支付业务逻辑和并发安全四条主线:越权包含水平越权和垂直越权;验证码包含图形验证码复用、万能验证码、弱图形验证码、短信验证码回显和参数绕过;支付包含金额篡改、订单重放、流程绕过、整数溢出和浮点数精度问题;并发安全包含竞态条件和幂等校验。 逻辑漏洞的本质不是单个危险 API,而是业务状态、权限边界、校验顺序或信任来源设计错误。修复时不能只依赖前端限制、隐藏菜单、不可见参数或客户端价格,应在服务端围绕“当前用户是谁、允许做什么、资源属于谁、订单处于什么状态、关键参数是否可信”建立统一校验,并配合幂等、事务、锁、审计和风控。 @@ -538,7 +538,8 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 | 垂直越权 | `GET /logic/idor/vertical/vul` 低权限用户直接访问管理员页面 | 覆盖管理员功能缺少服务端角色校验 | | 图形验证码 | 复用验证码、万能验证码、弱验证码识别、安全验证码 | 覆盖验证码生命周期、固定后门和识别难度问题 | | 短信验证码 | 验证码回显、`code_verify=true` 参数绕过 | 覆盖响应泄露和信任客户端校验结果 | -| 支付逻辑 | 金额篡改、订单重放、竞态、流程绕过、整数溢出、浮点精度 | 覆盖交易链路常见高风险业务逻辑问题 | +| 支付逻辑 | 金额篡改、订单重放、流程绕过、整数溢出、浮点精度 | 覆盖交易链路常见高风险业务逻辑问题 | +| 并发安全 | 竞态条件重复支付、同步锁和幂等校验 | 覆盖共享资源并发读写导致的重复扣款和状态竞争 | 后续如需增强,可补充“优惠券重复使用”“库存超卖”“负数数量/退款金额篡改”“越权修改订单状态”“多步骤流程跳步”“接口幂等Token”“风控频率限制”等场景。 @@ -599,6 +600,17 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 | 整数溢出 | `POST /logic/pay/vul5` | `count=2147483647&price=10` | `int` 乘法溢出,可能产生负金额并导致余额异常 | | 浮点精度 | `POST /logic/pay/vul6` | `count=0.1&price=0.2` | 返回实际扣款金额中可见二进制浮点误差 | +### 并发安全测试 + +页面:`/logic/concurrent` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /logic/concurrent` | 已登录会话 | 页面正常打开,展示竞态条件漏洞和同步锁/幂等安全场景 | +| 重置测试数据 | `POST /logic/concurrent/reset` | 无 | 返回余额重置为1000元,订单状态清空 | +| 竞态条件重复支付 | 并发 `POST /logic/concurrent/vul` | `orderId=race123&amount=100` | 多个相同订单请求可能同时成功,出现重复扣款或余额结果不一致 | +| 安全幂等校验 | 并发 `POST /logic/concurrent/safe` | `orderId=safeRace123&amount=100` | 首个请求扣款成功,后续相同订单返回“订单已支付,拒绝重复扣款” | + ## 其他漏洞 当前覆盖 URL 重定向、XFF 伪造、DoS 资源消耗和 XPath 注入四类容易散落在业务边缘的漏洞。该模块适合作为综合靶场的补充模块:不再围绕单一技术栈展开,而是展示真实项目中常被低估的输入信任、跳转控制、代理头信任、资源上限和表达式拼接问题。 diff --git a/src/main/java/top/whgojp/modules/logic/concurrent/controller/ConcurrentController.java b/src/main/java/top/whgojp/modules/logic/concurrent/controller/ConcurrentController.java new file mode 100644 index 0000000..3ea8248 --- /dev/null +++ b/src/main/java/top/whgojp/modules/logic/concurrent/controller/ConcurrentController.java @@ -0,0 +1,85 @@ +package top.whgojp.modules.logic.concurrent.controller; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import top.whgojp.common.utils.R; + +import java.math.BigDecimal; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * @description 逻辑漏洞-并发安全 + * @author: whgojp + * @email: whgojp@foxmail.com + * @Date: 2026/5/26 + */ +@Slf4j +@Api(value = "ConcurrentController", tags = "逻辑漏洞-并发安全") +@Controller +@CrossOrigin(origins = "*") +@RequestMapping("/logic/concurrent") +public class ConcurrentController { + private final AtomicReference userMoney = new AtomicReference<>(new BigDecimal("1000.00")); + private final Set paidOrders = ConcurrentHashMap.newKeySet(); + private final Object paymentLock = new Object(); + + @RequestMapping("") + public String concurrent() { + return "vul/logic/concurrent/concurrent"; + } + + @ApiOperation("漏洞场景:竞态条件重复支付") + @RequestMapping("/vul") + @ResponseBody + public R vul(@RequestParam String orderId, @RequestParam double amount) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + BigDecimal currentMoney = userMoney.get(); + BigDecimal payAmount = BigDecimal.valueOf(amount); + if (currentMoney.compareTo(payAmount) < 0) { + return R.error("余额不足"); + } + userMoney.set(currentMoney.subtract(payAmount)); + return R.ok("支付成功!订单:" + orderId + ",剩余余额:" + userMoney.get()); + } + + @ApiOperation("安全场景:同步锁和幂等校验") + @RequestMapping("/safe") + @ResponseBody + public R safe(@RequestParam String orderId, @RequestParam double amount) { + BigDecimal payAmount = BigDecimal.valueOf(amount); + synchronized (paymentLock) { + if (paidOrders.contains(orderId)) { + return R.error("订单已支付,拒绝重复扣款:" + orderId); + } + BigDecimal currentMoney = userMoney.get(); + if (currentMoney.compareTo(payAmount) < 0) { + return R.error("余额不足"); + } + paidOrders.add(orderId); + userMoney.set(currentMoney.subtract(payAmount)); + return R.ok("支付成功!订单:" + orderId + ",剩余余额:" + userMoney.get()); + } + } + + @ApiOperation("重置并发安全测试数据") + @RequestMapping("/reset") + @ResponseBody + public R reset() { + userMoney.set(new BigDecimal("1000.00")); + paidOrders.clear(); + return R.ok("余额已重置为1000.00元,订单状态已清空"); + } +} diff --git a/src/main/resources/static/api/init.json b/src/main/resources/static/api/init.json index ff32378..a1cd396 100755 --- a/src/main/resources/static/api/init.json +++ b/src/main/resources/static/api/init.json @@ -248,9 +248,10 @@ "href": "logic/pay", "icon": "iconfont icon-zhifu", "target": "_self" - }, { + }, + { "title": "并发安全", - "href": "", + "href": "logic/concurrent", "icon": "iconfont icon-gaobingfa", "target": "_self" }, @@ -481,4 +482,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/src/main/resources/static/js/staticcode.js b/src/main/resources/static/js/staticcode.js index dfd51db..113767d 100755 --- a/src/main/resources/static/js/staticcode.js +++ b/src/main/resources/static/js/staticcode.js @@ -1584,6 +1584,36 @@ const vul3Pay = "public R vul3(@RequestParam String orderId, @RequestParam doub " return R.ok(\"支付成功!剩余余额:\" + userMoney.get());\n" + "}"; +const vulConcurrent = "public R vul(@RequestParam String orderId, @RequestParam double amount) {\n" + + " // 模拟业务处理耗时,扩大并发窗口\n" + + " Thread.sleep(1000);\n" + + "\n" + + " BigDecimal currentMoney = userMoney.get();\n" + + " BigDecimal payAmount = BigDecimal.valueOf(amount);\n" + + " if (currentMoney.compareTo(payAmount) < 0) {\n" + + " return R.error(\"余额不足\");\n" + + " }\n" + + " // 漏洞点:读取余额和写回余额不是一个原子操作,相同订单也没有幂等校验\n" + + " userMoney.set(currentMoney.subtract(payAmount));\n" + + " return R.ok(\"支付成功!订单:\" + orderId + \",剩余余额:\" + userMoney.get());\n" + + "}"; + +const safeConcurrent = "public R safe(@RequestParam String orderId, @RequestParam double amount) {\n" + + " BigDecimal payAmount = BigDecimal.valueOf(amount);\n" + + " synchronized (paymentLock) {\n" + + " if (paidOrders.contains(orderId)) {\n" + + " return R.error(\"订单已支付,拒绝重复扣款:\" + orderId);\n" + + " }\n" + + " BigDecimal currentMoney = userMoney.get();\n" + + " if (currentMoney.compareTo(payAmount) < 0) {\n" + + " return R.error(\"余额不足\");\n" + + " }\n" + + " paidOrders.add(orderId);\n" + + " userMoney.set(currentMoney.subtract(payAmount));\n" + + " return R.ok(\"支付成功!订单:\" + orderId + \",剩余余额:\" + userMoney.get());\n" + + " }\n" + + "}"; + const vul4Pay = "@ApiOperation(\"支付流程绕过漏洞 - 创建订单\")\n" + "@RequestMapping(\"/vul4/create\")\n" + "public R createOrder(@RequestParam String orderId, @RequestParam double amount) {\n" + diff --git a/src/main/resources/templates/vul/logic/concurrent/concurrent.html b/src/main/resources/templates/vul/logic/concurrent/concurrent.html new file mode 100644 index 0000000..d4ba59d --- /dev/null +++ b/src/main/resources/templates/vul/logic/concurrent/concurrent.html @@ -0,0 +1,228 @@ + + + +
+ + + +
+
+
+
+
+ 逻辑漏洞 - 并发安全 +
+
  并发安全问题通常发生在库存、余额、订单、优惠券等共享资源读写过程中。多个请求同时执行时,如果校验、扣减和状态更新不是一个原子操作,就可能出现重复扣款、超卖、重复领取或状态覆盖。
+
+
+
+ +
+ +
+ +
+
+
+

漏洞场景:竞态条件重复支付

+
+
+
+
+
+
+ 订单ID:  + 金额:  +
+
+ +
+
+
+
+ +
+
+
tips
+
+
同一订单会同时发送 3 个支付请求。漏洞接口没有订单幂等校验,也没有把余额读取和写回放入同一个临界区,因此可能出现重复扣款或余额结果不一致。
+
+
+
+ +
+
+
测试结果
+
+

+                                        
+
+
+
+
+
+ +
+

缺陷代码

+
+
+
+
+
+
+ +
+
+
+

安全场景:加锁与幂等校验

+
+
+
+
+
+
+ 订单ID:  + 金额:  +
+
+ +
+
+
+
+ +
+
+
tips
+
+
安全接口在服务端使用订单幂等集合和同步锁保护临界区,同一订单只能扣款一次;真实业务还应配合数据库唯一索引、事务隔离、乐观锁或分布式锁。
+
+
+
+ +
+
+
测试结果
+
+

+                                        
+
+
+
+
+
+ +
+

安全代码

+
+
+
+
+
+
+
+
+
+ +
+ + + From b36cd38f693e16e2aeca8094f8e85ba206802ff9 Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Wed, 27 May 2026 10:27:01 +0800 Subject: [PATCH 14/19] Optimize login confrontation scenarios --- docs/instructions.md | 71 +++++++++++++++++++ .../controller/AccountController.java | 11 ++- .../controller/BypassController.java | 47 +++++++----- .../controller/CredentialController.java | 18 +++-- .../templates/vul/loginconfront/account.html | 6 +- .../templates/vul/loginconfront/bypass.html | 6 +- .../vul/loginconfront/credential.html | 12 ++-- .../templates/vul/loginconfront/reverse.html | 6 +- 8 files changed, 133 insertions(+), 44 deletions(-) diff --git a/docs/instructions.md b/docs/instructions.md index 485c951..a7a6c0f 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -745,4 +745,75 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 | 源码备份下载 | `GET /other/infoleak/www.zip` | 无 | 返回可下载压缩包,说明源码备份文件暴露在 Web 目录 | | 日志文件访问 | `GET /other/infoleak/JavaSecLab_logs.txt` | 无 | 返回日志内容,包含 SQL、账号、SessionId、验证码等敏感信息 | +## 登录对抗 + +当前覆盖账号安全、登录绕过、JS逆向和凭证安全四条主线:账号安全包含用户名枚举和弱口令;登录绕过包含修改响应包绕过和密码重置步骤绕过;JS逆向包含客户端签名复现和前端RSA加密绕过;凭证安全包含JWT声明伪造。 + +登录对抗类问题的本质是认证流程把关键信任放在了错误的位置:错误提示暴露账号状态、口令强度不足、客户端响应或步骤状态被服务端信任、前端算法和密钥可被逆向、令牌声明被过度信任。修复时应统一认证失败提示,实施强密码和限速策略,所有认证状态、流程状态、权限判断都在服务端完成,并配合MFA、风控、审计、短有效期令牌和密钥轮换。 + +已覆盖类型 + +| 分类 | 已有场景 | 结论 | +| --- | --- | --- | +| 账号安全 | 用户名枚举、弱口令 | 覆盖认证入口最常见的信息泄露和低强度口令风险 | +| 登录绕过 | 修改响应包绕过、密码重置步骤绕过 | 覆盖客户端状态可信和多步骤流程缺少服务端前置校验的问题 | +| JS逆向 | sign请求签名绕过、RSA前端加密绕过 | 覆盖前端算法、固定密钥、公钥加密不能作为安全边界的典型误区 | +| 凭证安全 | JWT声明伪造 | 覆盖静态密钥和过度信任令牌role声明导致的权限冒用风险 | + +模块覆盖基本符合综合性靶场定位,适合承接认证对抗相关场景。后续如需增强,可补充“登录失败频率限制缺失/爆破”“MFA验证码绕过”“Remember-Me Token伪造”“Session固定攻击”“OAuth回调redirect_uri校验缺陷”“账号锁定DoS”“密码找回Token弱随机/不过期”“JWT alg=none或kid/jku注入”等场景。当前标题中曾提到Session固定,但代码没有对应实现,已建议先从说明中移除,避免文档和功能不一致。 + +### 账号安全测试 + +页面:`/loginconfront/account` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /loginconfront/account` | 已登录会话 | 页面正常打开,展示用户名枚举和弱口令场景 | +| 用户名不存在 | `POST /loginconfront/account/vul1` | `username=qwer&password=x` | 返回“用户不存在!”,可据此判断账号不存在 | +| 用户名存在但密码错误 | `POST /loginconfront/account/vul1` | `username=admin&password=wrong` | 返回“密码错误,请重试!”,可据此判断账号存在 | +| 用户名枚举登录成功 | `POST /loginconfront/account/vul1` | `username=admin&password=admin123` | 返回登录成功 | +| 弱口令命中 | `POST /loginconfront/account/vul2` | `username=admin&password=admin` | 返回登录成功,说明默认/弱口令可直接突破认证 | +| 弱口令失败提示 | `POST /loginconfront/account/vul2` | `username=admin&password=wrong` | 返回统一的“账号或密码错误!” | + +### 登录绕过测试 + +页面:`/loginconfront/bypass`、`/loginconfront/bypass/reset` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /loginconfront/bypass` | 已登录会话 | 页面正常打开,展示修改响应包绕过和密码重置步骤绕过场景 | +| 第一阶段校验失败 | `POST /loginconfront/bypass/vul1step1` | `username=admin&password=wrong` | 返回“账号校验失败,请重试!” | +| 第一阶段校验成功 | `POST /loginconfront/bypass/vul1step1` | `username=admin&password=admin123` | 返回“账号校验通过,请稍等!” | +| 修改响应包绕过点 | `POST /loginconfront/bypass/vul1step2` | `code=0` | 返回“登录成功,欢迎!”,说明第二步只信任客户端传来的成功状态 | +| 密码重置页面 | `GET /loginconfront/bypass/reset` | 已登录会话 | 页面正常打开,展示三步密码重置流程 | +| 用户名步骤 | `POST /loginconfront/bypass/step1` | `username=admin` | 返回“用户名验证成功!” | +| 旧密码错误 | `POST /loginconfront/bypass/step2` | `oldPassword=bad` | 返回“旧密码错误!” | +| 正常旧密码校验 | `POST /loginconfront/bypass/step2` | `oldPassword=!@#qwf@3123` | 返回“密码验证成功!” | +| 跳过前置步骤重置 | `POST /loginconfront/bypass/step3` | `newPassword=newpass123` | 即使未完成旧密码校验也返回“密码重置成功!”,说明后端缺少步骤状态强校验 | + +### JS逆向测试 + +页面:`/loginconfront/reverse` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /loginconfront/reverse` | 已登录会话 | 页面正常打开,展示sign请求签名绕过和RSA前端加密绕过场景 | +| sign缺失/错误 | `POST /loginconfront/reverse/vul1` | 错误 `sign` | 返回“签名验证失败” | +| sign复现成功 | `POST /loginconfront/reverse/vul1` | 使用前端固定密钥和参数拼接规则生成MD5签名 | 返回“登录成功!用户名:admin,密码:admin123” | +| sign与参数不匹配 | `POST /loginconfront/reverse/vul1` | 修改密码但复用旧签名 | 返回“签名验证失败” | +| RSA密文错误 | `POST /loginconfront/reverse/vul2` | 非法密文或错误字段 | 返回“解密失败!” | +| RSA前端加密复现 | `POST /loginconfront/reverse/vul2` | 使用页面公钥加密 `admin/admin123` | 返回登录成功,说明公钥加密不能证明请求来自可信前端 | + +### 凭证安全测试 + +页面:`/loginconfront/credential` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /loginconfront/credential` | 已登录会话 | 页面正常打开,展示JWT声明伪造场景 | +| 生成JWT | `GET /loginconfront/credential/generate-jwt?username=admin&role=admin` | 任意用户名和角色声明 | 返回签名后的JWT | +| 缺少JWT | `GET /loginconfront/credential/vul1` | 不带 `Auth_Token` Header | 返回“缺少Auth_Token请求头” | +| 非法JWT | `GET /loginconfront/credential/vul1` | Header `Auth_Token: bad.jwt.token` | 返回JWT解析失败 | +| JWT解析成功 | `GET /loginconfront/credential/vul1` | Header携带生成的JWT | 返回 `user:admin,role:admin`,说明服务端信任令牌中的权限声明 | + # Java 专题 diff --git a/src/main/java/top/whgojp/modules/loginconfront/controller/AccountController.java b/src/main/java/top/whgojp/modules/loginconfront/controller/AccountController.java index 928cdd6..8665612 100644 --- a/src/main/java/top/whgojp/modules/loginconfront/controller/AccountController.java +++ b/src/main/java/top/whgojp/modules/loginconfront/controller/AccountController.java @@ -2,7 +2,6 @@ import io.swagger.annotations.Api; import lombok.extern.slf4j.Slf4j; -import org.apache.regexp.RE; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,8 +25,8 @@ @RequestMapping("/loginconfront/account") public class AccountController { // 测试账号密码 - final Set REAL_USERNAMES = new HashSet<>(Arrays.asList("admin", "test", "12345", "root")); - final String REAL_PASSWORD = "admin123"; + private static final Set REAL_USERNAMES = new HashSet<>(Arrays.asList("admin", "test", "12345", "root")); + private static final String REAL_PASSWORD = "admin123"; @RequestMapping("") public String account() { @@ -37,6 +36,9 @@ public String account() { @RequestMapping("/vul1") @ResponseBody public R vul1(String username, String password) { + if (username == null || username.trim().isEmpty() || password == null) { + return R.error("用户名或密码不能为空!"); + } if (REAL_USERNAMES.contains(username)) { if (REAL_PASSWORD.equalsIgnoreCase(password)) { return R.ok("登录成功!用户名:" + username + ", 密码:" + password); @@ -51,6 +53,9 @@ public R vul1(String username, String password) { @RequestMapping("/vul2") @ResponseBody public R vul2(String username, String password) { + if (username == null || username.trim().isEmpty() || password == null) { + return R.error("用户名或密码不能为空!"); + } // 这里简单模拟下数据库查询操作 // User user = UserService.getAllByUsernameAndPassword(username,password) if ("admin".equalsIgnoreCase(username) && "admin".equalsIgnoreCase(password)) { diff --git a/src/main/java/top/whgojp/modules/loginconfront/controller/BypassController.java b/src/main/java/top/whgojp/modules/loginconfront/controller/BypassController.java index cfd4093..75ecdf3 100644 --- a/src/main/java/top/whgojp/modules/loginconfront/controller/BypassController.java +++ b/src/main/java/top/whgojp/modules/loginconfront/controller/BypassController.java @@ -2,11 +2,11 @@ import io.swagger.annotations.Api; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import top.whgojp.common.utils.R; +import javax.servlet.http.HttpSession; import java.util.*; /** @@ -32,12 +32,16 @@ public String reset() { } // 测试账号密码 - final String REAL_USERNAME = "admin"; - final String REAL_PASSWORD = "admin123"; + private static final String REAL_USERNAME = "admin"; + private static final String REAL_PASSWORD = "admin123"; + private static final String RESET_FLOW_DATA = "loginconfrontResetFlowData"; @PostMapping("/vul1step1") @ResponseBody public R vul1step1(String username, String password) { + if (username == null || username.trim().isEmpty() || password == null) { + return R.error("账号校验失败,请重试!"); + } if (REAL_USERNAME.equalsIgnoreCase(username) && REAL_PASSWORD.equalsIgnoreCase(password)) { return R.ok("账号校验通过,请稍等!"); } else { @@ -55,21 +59,18 @@ public R vul1step2(String code) { } } - - private final Map stepData = new HashMap<>(); - - private final String oladPass = "!@#qwf@3123"; + private static final String OLD_PASS = "!@#qwf@3123"; // step1:验证用户名 @PostMapping("/step1") @ResponseBody - public R vul2Step1(@RequestParam String username) { + public R vul2Step1(@RequestParam String username, HttpSession session) { try { log.info("用户名:" + username); - if (username.isEmpty()) { + if (username == null || username.trim().isEmpty()) { return R.error("用户名不能为空"); } - stepData.put(1, username); + flowData(session).put(1, username); return R.ok("用户名验证成功!"); } catch (Exception e) { return R.error("服务器错误,请稍后再试"); @@ -80,27 +81,39 @@ public R vul2Step1(@RequestParam String username) { // step2:验证旧密码 @PostMapping("/step2") @ResponseBody - public R vul2Step2(@RequestParam String oldPassword) { - if (oldPassword.isEmpty()) { + public R vul2Step2(@RequestParam String oldPassword, HttpSession session) { + if (oldPassword == null || oldPassword.isEmpty()) { return R.error("旧密码不能为空!"); } - if (!oladPass.equals(oldPassword)) { + if (!OLD_PASS.equals(oldPassword)) { return R.error("旧密码错误!"); } - stepData.put(2, oldPassword); + flowData(session).put(2, oldPassword); return R.ok("密码验证成功!"); } // step3:设置新密码 @PostMapping("/step3") @ResponseBody - public R vul2Step3(@RequestParam String newPassword) { - if (newPassword.length() < 6) { + public R vul2Step3(@RequestParam String newPassword, HttpSession session) { + if (newPassword == null || newPassword.length() < 6) { return R.error("密码长度必须大于6!"); } + Map stepData = flowData(session); stepData.put(3, newPassword); - System.out.println("表单数据: " + stepData); + log.info("密码重置流程数据: {}", stepData); return R.ok("密码重置成功!"); } + @SuppressWarnings("unchecked") + private Map flowData(HttpSession session) { + Object data = session.getAttribute(RESET_FLOW_DATA); + if (data instanceof Map) { + return (Map) data; + } + Map stepData = new HashMap<>(); + session.setAttribute(RESET_FLOW_DATA, stepData); + return stepData; + } + } diff --git a/src/main/java/top/whgojp/modules/loginconfront/controller/CredentialController.java b/src/main/java/top/whgojp/modules/loginconfront/controller/CredentialController.java index b9774aa..daf50b7 100644 --- a/src/main/java/top/whgojp/modules/loginconfront/controller/CredentialController.java +++ b/src/main/java/top/whgojp/modules/loginconfront/controller/CredentialController.java @@ -1,7 +1,6 @@ package top.whgojp.modules.loginconfront.controller; import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; import io.swagger.annotations.Api; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -10,7 +9,6 @@ import top.whgojp.common.utils.R; import javax.crypto.spec.SecretKeySpec; -import javax.servlet.http.HttpSession; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -34,7 +32,6 @@ public String credential() { // 生成一个符合HS256要求的强密钥(至少256位) @Value("${jwt.key}") String secretKey = "f3a4c6d5b9bfeff28b1f529b0840134bcd4183474e2d4a97c05615a134e4f4da"; - Key key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); // Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); @@ -44,7 +41,7 @@ public R generateJWT(String username, String role) { String jwt = Jwts.builder() .setSubject(username) .claim("role", role) - .signWith(key) + .signWith(jwtKey()) .compact(); log.info("生成的JWT: " + jwt); return R.ok(jwt); @@ -52,16 +49,19 @@ public R generateJWT(String username, String role) { @RequestMapping("/vul1") @ResponseBody - public R vul1(@RequestHeader("Auth_Token") String jwt) { // 从请求头获取 JWT + public R vul1(@RequestHeader(value = "Auth_Token", required = false) String jwt) { // 从请求头获取 JWT + if (jwt == null || jwt.trim().isEmpty()) { + return R.error("缺少Auth_Token请求头"); + } log.info("获取到的JWT:" + jwt); try { String user = Jwts.parser() - .setSigningKey(key) + .setSigningKey(jwtKey()) .parseClaimsJws(jwt) .getBody() .getSubject(); String role = Jwts.parserBuilder() - .setSigningKey(key) + .setSigningKey(jwtKey()) .build() .parseClaimsJws(jwt) .getBody() @@ -80,4 +80,8 @@ public R vul2() { return R.ok(); } + private Key jwtKey() { + return new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); + } + } diff --git a/src/main/resources/templates/vul/loginconfront/account.html b/src/main/resources/templates/vul/loginconfront/account.html index ad314b2..a87e4e7 100644 --- a/src/main/resources/templates/vul/loginconfront/account.html +++ b/src/main/resources/templates/vul/loginconfront/account.html @@ -12,7 +12,7 @@

-

  这里以用户名枚举、弱口令为例,验证码等相关问题详见逻辑漏洞模块
+
  这里以用户名枚举、弱口令为例,演示认证阶段信息暴露和口令强度不足带来的账号接管风险。验证码等相关问题详见逻辑漏洞模块。

@@ -53,7 +53,7 @@

漏洞场景:用户名枚举

tips
-
  这里以本项目中Spring Security配置为例,实现UserDetailsService需要重写loadUserByUsername方法,return返回的内容如果是"用户名不存在",则攻击者可以根据返回内容进行枚举用户名,根据存活用户名进行下一步密码爆破
+
  登录失败时分别返回“用户不存在”和“密码错误”,会暴露账号是否存在。攻击者可先枚举有效用户名,再对有效账号做密码爆破。修复时应统一失败提示、控制登录频率,并记录异常登录行为。
@@ -118,7 +118,7 @@

漏洞场景:弱口令问题

tips
-
  尽管现代Web应用采用了多种安全架构、编码规范和框架,并部署了防火墙、IDS/IPS等安全防护措施,弱口令(如测试账号、默认密码、常见密码)依然是一个普遍的安全隐患。攻击者可以通过简单的暴力破解或字典(社工收集)攻击轻松突破认证。事实上,许多攻防演练的突破口仍然集中在弱口令……
+
  弱口令是认证体系中最常见的低成本突破口。默认密码、测试账号密码、常见字典口令会被自动化工具快速命中。修复时应启用强密码策略、默认口令强制修改、泄露密码库检测、多因素认证和登录失败限速。
diff --git a/src/main/resources/templates/vul/loginconfront/bypass.html b/src/main/resources/templates/vul/loginconfront/bypass.html index 6a9ef33..4f7579b 100644 --- a/src/main/resources/templates/vul/loginconfront/bypass.html +++ b/src/main/resources/templates/vul/loginconfront/bypass.html @@ -12,7 +12,7 @@

-

  这里以修改响应包绕过、密码重置步骤绕过场景为例
+
  这里以修改响应包绕过、密码重置步骤绕过为例,演示认证流程中将关键判断交给客户端或缺少服务端状态校验造成的绕过风险。

@@ -53,7 +53,7 @@

漏洞场景:修改响应包绕过
tips
-
  在前后端分离架构中(如RESTful风格),前端通常通过调用后端API来验证用户登录状态。后端接口返回状态码(200、500)或结果字段(error、success)直接决定前端的后续操作。当后端逻辑中直接信任客户端传递的状态码或响应结果,攻击者可以通过修改API响应包(error->success,500->200)来绕过登录校验,伪造成功状态
+
  如果前端仅根据接口响应字段进入下一步,而后端后续接口又信任这个客户端状态,攻击者可通过抓包把失败响应改成成功响应,诱导前端继续调用敏感接口。修复时必须在服务端保存并校验认证状态,不能把登录成功与否交给前端决定。
@@ -106,7 +106,7 @@

漏洞场景:密码重置步骤绕过
tips
-
  由于前端通过step参数控制流程,可以通过抓包工具拦截请求并直接修改step的值,从而绕过旧密码校验的步骤,直接进入设置新密码的阶段,核心问题在于流程的关键验证依赖于前端,而不是在后端进行严格的状态管理与校验
+
  多步骤流程只在前端切换页面状态,后端第三步接口未校验当前会话是否已经完成用户名和旧密码验证。攻击者可直接调用设置新密码接口完成重置。修复时应在服务端维护流程状态,并在每一步校验前置步骤、用户身份和一次性令牌。
diff --git a/src/main/resources/templates/vul/loginconfront/credential.html b/src/main/resources/templates/vul/loginconfront/credential.html index 3cba37d..d4e034f 100644 --- a/src/main/resources/templates/vul/loginconfront/credential.html +++ b/src/main/resources/templates/vul/loginconfront/credential.html @@ -12,7 +12,7 @@

-

  这里以JWT伪造、Session固定攻击为例……
+
  这里以JWT声明伪造为例,演示令牌签名密钥固定、权限声明可控和服务端过度信任令牌内容带来的凭证安全风险。

@@ -56,7 +56,7 @@

漏洞场景:JWT伪造

tips
-
test
+
  JWT一旦签名密钥泄露、过弱或长期固定,攻击者可构造带有高权限role声明的令牌并通过服务端验签。修复时应使用足够强度的密钥并妥善保管,校验iss、aud、exp等关键声明,服务端权限以可信数据源为准,不应只相信客户端提交的role。
@@ -109,9 +109,7 @@

缺陷代码

form.on('submit(vul1-credential-button)', function (data) { - event.preventDefault(); // 防止表单默认提交行为 const jwtValue = $("input[name='jwt']").val(); // 获取输入框的值 - console.log(jwtValue) $.ajax({ url: "/loginconfront/credential/vul1", // 发送到正确的后端接口 type: "get", @@ -122,9 +120,10 @@

缺陷代码

$("#vul1-credential-result").text(result.msg); // 显示返回信息 }, error: function () { - alert("请求发送失败!"); + $("#vul1-credential-result").text("请求发送失败!"); } }); + return false; }) @@ -142,9 +141,6 @@

缺陷代码

CodeMirror(document.getElementById("vul1Credential"), Object.assign({}, cmConfig, { value: vul1Credential })); - CodeMirror(document.getElementById("vul2Credential"), Object.assign({}, cmConfig, { - value: vul2Credential - })); }); }); diff --git a/src/main/resources/templates/vul/loginconfront/reverse.html b/src/main/resources/templates/vul/loginconfront/reverse.html index 7e72b29..eba1b3c 100644 --- a/src/main/resources/templates/vul/loginconfront/reverse.html +++ b/src/main/resources/templates/vul/loginconfront/reverse.html @@ -28,7 +28,7 @@

-

  JavaScript加密主要用于确保数据在传输和存储过程中的安全性,防止敏感信息被未授权方窃取或篡改……
+
  这里以请求签名和前端RSA加密为例,演示把安全边界建立在前端算法、固定密钥或可逆向代码上时,攻击者仍可复现请求并绕过校验。

@@ -69,7 +69,7 @@

漏洞场景:sign请求签名绕过
tips
-
  增加签名(sign)可以防止请求篡改、重放攻击以及恶意API调用,但是当sign生成复杂度较低时(简单的哈希算法和时间戳),可以通过逆向分析JS代码(例如generateSign函数),伪造sign参数从而绕过验证
+
  请求签名能提高伪造成本,但如果签名密钥写在前端、算法只是固定参数拼接后MD5,攻击者通过逆向JS即可复现sign。修复时签名密钥应只存在服务端,并结合随机数、时间窗口、重放缓存和服务端业务鉴权。
@@ -134,7 +134,7 @@

漏洞场景:RSA前端加密绕过
tips
-
  攻击者可以通过获取公钥伪造数据,绕过后端的逻辑处理……
+
  前端RSA加密只能保护传输内容不被直接明文观察,不能证明请求来自可信前端。公钥天然可公开,攻击者拿到公钥后同样可以加密任意账号密码并调用接口。修复时应依赖HTTPS、服务端认证、风控和重放防护,不能把“前端加密”当作访问控制。
From 01dc6a6f2466684b89c82be27530581b921f0785 Mon Sep 17 00:00:00 2001 From: whgojp <3242246891@qq.com> Date: Wed, 27 May 2026 10:44:56 +0800 Subject: [PATCH 15/19] Optimize SpringBoot vulnerability scenarios --- docs/instructions.md | 70 +++++++++++++ .../springboot/config/DruidMonitorConfig.java | 32 ++++++ .../controller/SpringBootController.java | 98 ++++++++++++------- .../springboot/entity/MaliciousObject.java | 4 +- .../whgojp/security/SecurityConfigurer.java | 2 +- src/main/resources/static/js/staticcode.js | 27 +++-- .../templates/vul/springboot/springboot.html | 53 ++++++---- 7 files changed, 217 insertions(+), 69 deletions(-) create mode 100644 src/main/java/top/whgojp/modules/springboot/config/DruidMonitorConfig.java diff --git a/docs/instructions.md b/docs/instructions.md index a7a6c0f..c4c9313 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -817,3 +817,73 @@ RCE的本质是不可信输入进入服务端“可执行上下文”。命令 | JWT解析成功 | `GET /loginconfront/credential/vul1` | Header携带生成的JWT | 返回 `user:admin,role:admin`,说明服务端信任令牌中的权限声明 | # Java 专题 + +## SpringBoot 框架相关漏洞 + +当前覆盖 Swagger/OpenAPI 文档暴露、Spring Boot Actuator 敏感端点暴露、Druid 监控台暴露、MySQL JDBC 反序列化四类 Spring Boot 生态常见风险。模块重点不是 Spring Boot 框架自身漏洞,而是框架生态中“开发/运维辅助能力被带到生产环境”后的暴露面。 + +Spring Boot 相关漏洞的本质通常是配置边界和运行时暴露面管理不当:接口文档、管理端点、监控台、数据源连接和驱动参数本应用于开发、运维或内部系统,却被公网或普通用户访问。修复时应遵循生产环境最小暴露原则,关闭不必要组件,对管理入口加鉴权和内网限制,敏感端点脱敏,禁止用户控制 JDBC URL,并避免 Java 原生反序列化不可信数据。 + +已覆盖类型 + +| 分类 | 已有场景 | 结论 | +| --- | --- | --- | +| Swagger/OpenAPI | `/v3/api-docs`、Swagger UI | 覆盖接口文档未鉴权导致接口、参数和模型信息泄漏 | +| Actuator | `/sys/actuator`、`/sys/actuator/health` | 覆盖管理端点暴露和健康详情泄漏 | +| Druid 监控台 | `/druid/index.html` | 覆盖连接池监控台暴露导致 SQL、URI、Session、数据源信息泄漏 | +| MySQL JDBC 反序列化 | `/springboot/vul`、`/springboot/insert`、`/springboot/jdbc` | 覆盖不可信 JDBC URL 和从数据库读取字节流后原生反序列化的风险链路 | + +模块覆盖基本符合综合性靶场定位,适合作为 Spring Boot 生态暴露面的专题模块。后续如需增强,可补充“Spring Boot DevTools 远程调试暴露”“Spring Cloud Gateway Actuator RCE”“Spring Cloud Config 配置泄漏”“Eureka/Nacos/Sentinel 控制台未授权”“错误堆栈和 Whitelabel Error 信息泄漏”“Jolokia JMX 暴露”“Heapdump 敏感信息提取”等场景。当前模块没有明显冗余,但 Druid 场景应明确是 Alibaba Druid 连接池监控台,避免和 Apache Druid 分析数据库混淆。 + +### SpringBoot 页面与资源测试 + +页面:`/springboot` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| 页面访问 | `GET /springboot` | 已登录会话 | 页面正常打开,展示 Swagger、Actuator、Druid、MySQL JDBC 四类场景 | +| Swagger 流量包 | `GET /other/datapackage/springboot/swagger_ui.pcapng` | 无 | 返回可下载流量包 | +| Actuator 流量包 | `GET /other/datapackage/springboot/actuator.pcapng` | 无 | 返回可下载流量包 | +| Druid 流量包 | `GET /other/datapackage/springboot/druid.pcapng` | 无 | 返回可下载流量包 | +| MySQL JDBC 流量包 | `GET /other/datapackage/springboot/mysql_jdbc.pcapng` | 无 | 返回可下载流量包 | + +### Swagger/OpenAPI 暴露测试 + +页面:`/springboot` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| OpenAPI JSON | `GET /v3/api-docs` | 已登录会话 | 返回 OpenAPI 文档,内容包含 `openapi`、`paths` 等接口描述 | +| Swagger UI | `GET /swagger-ui/index.html` | 已登录会话 | Swagger UI 页面可访问 | +| 风险确认 | 查看 `/v3/api-docs` 内容 | 无 | 可看到后端接口路径、参数、模型信息,说明接口文档未做生产隔离 | + +### Actuator 端点暴露测试 + +页面:`/springboot` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| Actuator 根端点 | `GET /sys/actuator` | 已登录会话 | 返回 `_links`,列出可访问管理端点 | +| 健康详情 | `GET /sys/actuator/health` | 已登录会话 | 返回 `status` 和组件详情 | +| 映射端点 | `GET /sys/actuator/mappings` | 已登录会话 | 返回应用请求映射信息,说明路由信息可枚举 | + +### Druid 监控台暴露测试 + +页面:`/springboot` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| Druid 首页 | `GET /druid/index.html` | 无需登录或已登录会话 | 返回 Druid 监控页面 | +| Druid 数据源信息 | `GET /druid/datasource.json` | 无需登录或已登录会话 | 返回数据源监控 JSON | +| Druid URI 统计 | `GET /druid/weburi.json` | 无需登录或已登录会话 | 返回 Web URI 访问统计 | + +### MySQL JDBC 反序列化测试 + +页面:`/springboot` + +| 场景 | 请求 | 测试输入 | 预期结果 | +| --- | --- | --- | --- | +| JDBC URL 缺失 | `GET /springboot/vul` | 不传 `url` | 返回“JDBC URL不能为空” | +| Fake MySQL 连接尝试 | `GET /springboot/vul?url=jdbc:mysql://127.0.0.1:1/test&username=root&password=x` | 不可用 MySQL 地址 | 返回 JDBC 连接失败,说明服务端会使用用户传入的 JDBC URL 发起连接 | +| 插入测试对象 | `GET /springboot/insert?command=true` | 安全测试命令 `true` | 返回“恶意对象插入成功!” | +| 触发本地反序列化链路 | `GET /springboot/jdbc` | 依赖上一步写入对象 | 返回“触发MYSQL-JDBC反序列化漏洞!” | diff --git a/src/main/java/top/whgojp/modules/springboot/config/DruidMonitorConfig.java b/src/main/java/top/whgojp/modules/springboot/config/DruidMonitorConfig.java new file mode 100644 index 0000000..2d45fc0 --- /dev/null +++ b/src/main/java/top/whgojp/modules/springboot/config/DruidMonitorConfig.java @@ -0,0 +1,32 @@ +package top.whgojp.modules.springboot.config; + +import com.alibaba.druid.support.http.StatViewServlet; +import com.alibaba.druid.support.http.WebStatFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Demonstrates an exposed Druid monitor console for the Spring Boot scenario. + */ +@Configuration +public class DruidMonitorConfig { + + @Bean + public ServletRegistrationBean druidStatViewServlet() { + ServletRegistrationBean registrationBean = + new ServletRegistrationBean<>(new StatViewServlet(), "/druid/*"); + registrationBean.addInitParameter("resetEnable", "false"); + return registrationBean; + } + + @Bean + public FilterRegistrationBean druidWebStatFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new WebStatFilter()); + registrationBean.addUrlPatterns("/*"); + registrationBean.addInitParameter("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"); + return registrationBean; + } +} diff --git a/src/main/java/top/whgojp/modules/springboot/controller/SpringBootController.java b/src/main/java/top/whgojp/modules/springboot/controller/SpringBootController.java index aa0ed81..93c48c3 100644 --- a/src/main/java/top/whgojp/modules/springboot/controller/SpringBootController.java +++ b/src/main/java/top/whgojp/modules/springboot/controller/SpringBootController.java @@ -35,81 +35,107 @@ public String springboot() { } @Value("${spring.datasource.url:jdbc:mysql://localhost:13306/JavaSecLab}") + String legacyUrl = ""; + @Value("${spring.datasource.username:root}") + String legacyUsername = ""; + @Value("${spring.datasource.password:QWE123qwe}") + String legacyPassword = ""; + @Value("${spring.datasource.primary.url:}") String url = ""; - @Value("${spring.datasource.username:jdbc:root}") + @Value("${spring.datasource.primary.username:}") String username = ""; - @Value("${spring.datasource.password:jdbc:QWE123qwe}") + @Value("${spring.datasource.primary.password:}") String password = ""; @RequestMapping("/jdbc") @ResponseBody public R jdbc() { - try { - Connection conn = DriverManager.getConnection(url, username, password); + try (Connection conn = DriverManager.getConnection(resolveUrl(), resolveUsername(), resolvePassword()); + Statement stmt = conn.createStatement()) { String selectQuery = "SELECT malicious_object FROM objects WHERE id = 1"; - Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(selectQuery); - if (rs.next()) { + try (ResultSet rs = stmt.executeQuery(selectQuery)) { + if (!rs.next()) { + return R.error("未找到恶意对象,请先点击“反序列化命令”插入测试数据"); + } byte[] maliciousObjectBytes = rs.getBytes("malicious_object"); - // 反序列化恶意对象 - ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(maliciousObjectBytes); - ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream); - - MaliciousObject maliciousObject = (MaliciousObject) objectInputStream.readObject(); - - log.info("触发MYSQL-JBDC反序列化漏洞!"); + if (maliciousObjectBytes == null || maliciousObjectBytes.length == 0) { + return R.error("恶意对象内容为空"); + } + try (ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(maliciousObjectBytes))) { + objectInputStream.readObject(); + } } - stmt.close(); - conn.close(); - + log.info("触发MYSQL-JDBC反序列化漏洞!"); + return R.ok("触发MYSQL-JDBC反序列化漏洞!"); } catch (Exception e) { - e.printStackTrace(); + log.error("触发MYSQL-JDBC反序列化漏洞失败", e); + return R.error("触发MYSQL-JDBC反序列化漏洞失败:" + e.getMessage()); } - return R.ok("触发MYSQL-JBDC反序列化漏洞!"); } @RequestMapping("/insert") @ResponseBody public R insertMaliciousObject(@RequestParam String command) { - try { + if (command == null || command.trim().isEmpty()) { + return R.error("命令不能为空"); + } + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { MaliciousObject maliciousObject = new MaliciousObject(command); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(maliciousObject); byte[] objectBytes = baos.toByteArray(); - Connection conn = DriverManager.getConnection(url, username, password); - String insertQuery = "INSERT INTO objects (id, malicious_object) VALUES (?, ?)"; - PreparedStatement stmt = conn.prepareStatement(insertQuery); - stmt.setInt(1, 1); - stmt.setBytes(2, objectBytes); - stmt.executeUpdate(); - stmt.close(); - conn.close(); + try (Connection conn = DriverManager.getConnection(resolveUrl(), resolveUsername(), resolvePassword()); + PreparedStatement stmt = conn.prepareStatement("REPLACE INTO objects (id, malicious_object) VALUES (?, ?)")) { + stmt.setInt(1, 1); + stmt.setBytes(2, objectBytes); + stmt.executeUpdate(); + } return R.ok("恶意对象插入成功!"); } catch (Exception e) { - e.printStackTrace(); - return R.error("恶意对象插入失败(主键冲突的话在请数据库中删除该记录):" + e.getMessage()); + log.error("恶意对象插入失败", e); + return R.error("恶意对象插入失败:" + e.getMessage()); } } @RequestMapping("/vul") @ResponseBody public R vul(String url, String username, String password) { + if (url == null || url.trim().isEmpty()) { + return R.error("JDBC URL不能为空"); + } try { // Class.forName("com.mysql.jdbc.Driver"); - Class.forName("com.mysql.cj.jdbc.Driver"); - DriverManager.getConnection(url,username,password); + Class.forName("com.mysql.cj.jdbc.Driver"); + DriverManager.setLoginTimeout(5); + try (Connection ignored = DriverManager.getConnection(url, username, password)) { + return R.ok("JDBC连接请求已发送"); + } } catch (Exception e) { - e.printStackTrace(); + log.error("JDBC连接失败", e); + return R.error("JDBC连接失败:" + e.getMessage()); } - return R.ok(); } + private String resolveUrl() { + return isBlank(url) ? legacyUrl : url; + } + + private String resolveUsername() { + return isBlank(username) ? legacyUsername : username; + } + + private String resolvePassword() { + return isBlank(password) ? legacyPassword : password; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } } diff --git a/src/main/java/top/whgojp/modules/springboot/entity/MaliciousObject.java b/src/main/java/top/whgojp/modules/springboot/entity/MaliciousObject.java index 34ed3cd..36074e9 100644 --- a/src/main/java/top/whgojp/modules/springboot/entity/MaliciousObject.java +++ b/src/main/java/top/whgojp/modules/springboot/entity/MaliciousObject.java @@ -5,6 +5,8 @@ import java.io.Serializable; public class MaliciousObject implements Serializable { + private static final long serialVersionUID = -4609530693199052538L; + private String command; public MaliciousObject(String command) { @@ -12,7 +14,7 @@ public MaliciousObject(String command) { } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { - String command = (String) in.readObject(); + in.defaultReadObject(); if (command != null) { Runtime.getRuntime().exec(command); } diff --git a/src/main/java/top/whgojp/security/SecurityConfigurer.java b/src/main/java/top/whgojp/security/SecurityConfigurer.java index cf0112d..0103c51 100755 --- a/src/main/java/top/whgojp/security/SecurityConfigurer.java +++ b/src/main/java/top/whgojp/security/SecurityConfigurer.java @@ -90,7 +90,7 @@ protected void configure(HttpSecurity http) throws Exception { permitAll.add("/other/**"); permitAll.add("/ssrf/internal/**"); permitAll.add("/ssrf/redirect"); -// permitAll.add("/druid/**"); + permitAll.add("/druid/**"); // permitAll.add("/ueditor/**"); String[] urls = permitAll.stream().distinct().toArray(String[]::new); diff --git a/src/main/resources/static/js/staticcode.js b/src/main/resources/static/js/staticcode.js index 113767d..d2d0a44 100755 --- a/src/main/resources/static/js/staticcode.js +++ b/src/main/resources/static/js/staticcode.js @@ -2136,7 +2136,7 @@ const springBootSwagger = "return new Docket(DocumentationType.OAS_30)\n" + const springBootActuator = "management:\n" + " # 端点信息接口使用的端口,为了和主系统接口使用的端口进行分离\n" + " server:\n" + - " port: 8080\n" + + " port: 80\n" + " # 端点健康情况,默认值\"never\",设置为\"always\"可以显示硬盘使用情况和线程情况\n" + " endpoint:\n" + " health:\n" + @@ -2166,19 +2166,18 @@ const springBootActuator = "management:\n" + "jolokia 通过HTTP暴露JMX beans(当Jolokia在类路径上时,WebFlux不可用) Yes\n" + "logfile 返回日志文件内容(如果设置了logging.file或logging.path属性的话),支持使用HTTP Range头接收日志文件内容的部分信息 Yes\n" + "prometheus 以可以被Prometheus服务器抓取的格式显示metrics信息 Yes"; -const springBootDruid = "druid:\n" + - " ...\n" + - " filters: stat,log4j # wall 这里关闭sql防火墙\n" + - " stat-view-servlet:\n" + - " enabled: true\n" + - " url-pattern: /druid/*\n" + - "# login-username: admin\n" + - "# login-password: admin\n" + - " reset-enable: false\n" + - " # 防火墙配置\n" + - "# wall:\n" + - "# config:\n" + - "# multi-statement-allow: false" +const springBootDruid = "@Configuration\n" + + "public class DruidMonitorConfig {\n" + + " @Bean\n" + + " public ServletRegistrationBean druidStatViewServlet() {\n" + + " ServletRegistrationBean registrationBean =\n" + + " new ServletRegistrationBean<>(new StatViewServlet(), \"/druid/*\");\n" + + " registrationBean.addInitParameter(\"resetEnable\", \"false\");\n" + + " return registrationBean;\n" + + " }\n" + + "}\n" + + "\n" + + "// SecurityConfigurer 中放行 /druid/**,且未设置登录账号密码,会导致Druid监控台暴露。" const dirTraversal = 'public String listDirectory(String dir) {\n' + ' String staticFolderPath = sysConstant.getStaticFolder();\n' + diff --git a/src/main/resources/templates/vul/springboot/springboot.html b/src/main/resources/templates/vul/springboot/springboot.html index 32b2b43..db495f6 100644 --- a/src/main/resources/templates/vul/springboot/springboot.html +++ b/src/main/resources/templates/vul/springboot/springboot.html @@ -15,8 +15,8 @@

-

  Spring Boot是一个通过自动配置、嵌入式服务器和生产级功能,简化了Spring应用程序开发、配置和部署的框架,使开发者能够快速构建独立且易于部署的Java应用
-  在其生态系统下,也存在一些相关组件的安全隐患,如Swagger UI可能导致敏感接口泄漏,Spring Boot Actuator可能暴露敏感端口,Druid可能面临未授权访问风险,以及Spring Cloud Gateway存在远程代码执行漏洞等
+
  Spring Boot通过自动配置、嵌入式服务器和生产级功能简化了Java应用开发,但调试、监控、文档和数据源组件一旦在生产环境暴露,就可能泄漏接口、配置、运行状态、SQL监控数据,甚至形成反序列化利用链。
+  本模块聚焦Spring Boot常见暴露面:Swagger/OpenAPI文档、Actuator管理端点、Druid监控台,以及MySQL JDBC反序列化相关风险。

@@ -49,7 +49,7 @@

tips
-
  Swagger是一种用于描述API的开源框架,它使用OpenAPI规范来定义API的端点、请求、响应、模式等。Swagger接口泄露漏洞是指在使用Swagger描述API时,由于未正确配置访问控制或未实施安全措施,导致API接口被不授权的人员访问和利用,从而导致系统安全风险
+
  Swagger/OpenAPI文档会集中暴露接口路径、参数、数据结构和调试入口。若生产环境未鉴权开放,攻击者可快速梳理后台接口、发现未授权或越权入口。修复时应在生产环境关闭文档,或增加强鉴权、网络访问控制和环境隔离。
@@ -96,7 +96,7 @@

tips
-
  Spring Boot Actuator 是一个用于监控和管理 Spring Boot 应用程序的功能模块。它提供了一系列生产就绪的功能,帮助你了解应用程序的运行状况,以及在运行时对应用程序进行调整。Actuator 使用了 Spring MVC 来暴露各种 HTTP 或 JMX 端点,通过这些端点你可以获取到应用程序的运行信息,如健康状态、指标、线程 dump、环境变量等
+
  Actuator用于健康检查和运行时监控,但暴露`env`、`beans`、`mappings`、`heapdump`、`threaddump`等端点会泄漏配置、依赖、路由、内存和线程信息。修复时应最小化暴露端点,只开放必要健康检查,并对管理端口加鉴权、内网限制和脱敏。
@@ -120,7 +120,7 @@

- 漏洞情景:Druid 配置不当 + 漏洞场景:Druid 监控台暴露 流量分析 @@ -144,8 +144,7 @@

tips
-
  Apache Druid 是阿里开源的高性能连接池和实时分析分布式数据存储系统,专为大规模时序数据和事件流分析设计,具有快速查询、低延迟写入及横向扩展能力
-  在导入Druid依赖时,没有在application.yml中添加配置或忘记配置账号密码(当然弱口令也是一个常见问题),此时Druid可以未授权访问,攻击者可直接获取websession信息,进行session爆破,以及通过weburi获取敏感信息路径,进行Fuzz攻击
+
  这里的Druid指Alibaba Druid连接池监控台,不是Apache Druid分析数据库。若`/druid/*`监控台未设置鉴权或被安全配置放行,攻击者可查看数据源、SQL、URI、Session等运行信息。修复时应关闭生产监控台,或设置强口令、内网访问、反向代理鉴权和访问审计。

@@ -170,7 +169,7 @@

@@ -246,18 +244,39 @@

"); - response.append(""); - response.append(""); - response.append("

Directory listing for ").append(dir).append("

"); - response.append("
"); - response.append("
"); - response.append("
"); - response.append(""); - response.append(""); - return response.toString(); + return renderDirectoryListing(dir, requestedDir, false); } @GetMapping("/safe1") @ResponseBody @SneakyThrows - public String safe1(@RequestParam String dir) { - String staticFolderPath = sysConstant.getStaticFolder(); - File baseDir = new File(staticFolderPath); + public String safe1(@RequestParam(defaultValue = "/") String dir) { + File baseDir = resolveStaticBaseDir(); String decodedDir = URLDecoder.decode(dir, StandardCharsets.UTF_8.name()); @@ -106,106 +62,90 @@ public String safe1(@RequestParam String dir) { } File requestedDir = new File(baseDir, dir); - - // 生成HTML输出 - StringBuilder response = new StringBuilder(); - response.append(""); - response.append(""); - response.append(""); - response.append(""); - response.append("Directory listing for ").append(dir).append(""); - response.append(""); - response.append(""); - response.append(""); - response.append("

Directory listing for ").append(dir).append("

"); - response.append("
"); - response.append("
    "); - - File[] files = requestedDir.listFiles(); - if (files != null) { - for (File file : files) { - response.append("
  • "); - if (file.isDirectory()) { - response.append("").append(file.getName()).append("/"); - } else { - response.append("").append(file.getName()).append(""); - } - response.append("
  • "); - } - } else { - response.append("Failed to list contents of the directory."); - } - - response.append("
"); - response.append("
"); - response.append(""); - response.append(""); - return response.toString(); + return renderDirectoryListing(dir, requestedDir, false); } + @GetMapping("/safe2") @ResponseBody - public String safe2(@RequestParam String dir) { - String staticFolderPath = sysConstant.getStaticFolder(); - File baseDir = new File(staticFolderPath); - File requestedDir = new File(baseDir, dir); + public String safe2(@RequestParam(defaultValue = "/") String dir) { + File baseDir = resolveStaticBaseDir(); + String relativeDir = normalizeRelativeDir(dir); // 检查请求的目录是否在规定目录内 try { - if (!requestedDir.getCanonicalPath().startsWith(baseDir.getCanonicalPath()) || !requestedDir.isDirectory()) { + Path basePath = baseDir.getCanonicalFile().toPath(); + File requestedDir = new File(baseDir, relativeDir); + Path requestedPath = requestedDir.getCanonicalFile().toPath(); + if (!requestedPath.startsWith(basePath) || !requestedDir.isDirectory()) { return "Directory not found or access denied."; } + return renderDirectoryListing(dir, requestedDir, true); } catch (IOException e) { return "Error resolving directory path."; } + } + + private File resolveStaticBaseDir() { + File configuredDir = new File(sysConstant.getStaticFolder()); + if (configuredDir.isDirectory() && hasVisibleFiles(configuredDir)) { + return configuredDir; + } - // 生成HTML输出 + try { + java.net.URL resource = getClass().getClassLoader().getResource("static"); + if (resource != null && "file".equals(resource.getProtocol())) { + return new File(resource.toURI()); + } + } catch (URISyntaxException e) { + log.warn("解析classpath静态目录失败", e); + } + + return configuredDir; + } + + private boolean hasVisibleFiles(File dir) { + File[] files = dir.listFiles(file -> !file.isHidden()); + return files != null && files.length > 0; + } + + private String normalizeRelativeDir(String dir) { + if (dir == null || dir.isEmpty() || "/".equals(dir)) { + return ""; + } + return dir.startsWith("/") ? dir.substring(1) : dir; + } + + private String renderDirectoryListing(String displayDir, File requestedDir, boolean keepInsideBase) { + String normalizedDisplayDir = displayDir == null || displayDir.isEmpty() ? "/" : displayDir; StringBuilder response = new StringBuilder(); response.append(""); response.append(""); response.append(""); response.append(""); - response.append("Directory listing for ").append(dir).append(""); - response.append(""); + response.append("Directory listing for ").append(escapeHtml(normalizedDisplayDir)).append(""); response.append(""); response.append(""); - response.append("

Directory listing for ").append(dir).append("

"); + response.append("

Directory listing for ").append(escapeHtml(normalizedDisplayDir)).append("

"); response.append("
"); response.append("
    "); File[] files = requestedDir.listFiles(); if (files != null) { + Arrays.sort(files, Comparator.comparing(File::isFile).thenComparing(File::getName)); for (File file : files) { response.append("
  • "); + String itemName = file.getName(); + String childDir = appendPath(normalizedDisplayDir, itemName, file.isDirectory()); if (file.isDirectory()) { - response.append("").append(file.getName()).append("/"); + response.append("") + .append(escapeHtml(itemName)).append("/"); } else { - response.append("") + .append(escapeHtml(itemName)).append(""); } - response.append(file.getName()).append("');\">").append(file.getName()).append(""); } response.append("
  • "); } @@ -219,4 +159,28 @@ public String safe2(@RequestParam String dir) { response.append(""); return response.toString(); } + + private String appendPath(String dir, String name, boolean directory) { + String prefix = (dir == null || dir.isEmpty() || "/".equals(dir)) ? "/" : dir + (dir.endsWith("/") ? "" : "/"); + return prefix + name + (directory ? "/" : ""); + } + + private String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 encoding is not supported", e); + } + } + + private String escapeHtml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } } diff --git a/src/main/java/top/whgojp/security/SecurityConfigurer.java b/src/main/java/top/whgojp/security/SecurityConfigurer.java index 0103c51..51ee6aa 100755 --- a/src/main/java/top/whgojp/security/SecurityConfigurer.java +++ b/src/main/java/top/whgojp/security/SecurityConfigurer.java @@ -110,8 +110,8 @@ protected void configure(HttpSecurity http) throws Exception { // http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); - // 如果不需要验证码校验登录 可以注释掉该行 -// http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class); + // 登录验证码校验,验证码一次性使用,避免同一验证码被重复提交。 + http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class); // 添加session管理器 session失效后跳到登录页 diff --git a/src/main/java/top/whgojp/security/handler/CustomSimpleUrlAuthenticationFailureHandler.java b/src/main/java/top/whgojp/security/handler/CustomSimpleUrlAuthenticationFailureHandler.java index 0ac6635..ebca529 100755 --- a/src/main/java/top/whgojp/security/handler/CustomSimpleUrlAuthenticationFailureHandler.java +++ b/src/main/java/top/whgojp/security/handler/CustomSimpleUrlAuthenticationFailureHandler.java @@ -10,7 +10,6 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import top.whgojp.common.constant.SysConstant; import top.whgojp.common.enums.LoginError; @@ -26,14 +25,10 @@ public class CustomSimpleUrlAuthenticationFailureHandler extends SimpleUrlAuthen private static final String DEFAULT_FAILURE_URL = SysConstant.LOGIN_URL; - private String defaultFailureUrl; - - - @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - super.onAuthenticationFailure(request, response, exception); setDefaultFailureUrl(determineFailureUrl(exception)); + super.onAuthenticationFailure(request, response, exception); log.info("当前异常:"+exception.getMessage()); String loginIp = request.getRemoteHost(); @@ -50,24 +45,22 @@ public void CustomOnAuthenticationFailure(Exception exception){ } private String determineFailureUrl(AuthenticationException exception) { - // 默认设置登录错误页面为/login - defaultFailureUrl = StringUtils.hasLength(defaultFailureUrl) ? defaultFailureUrl : DEFAULT_FAILURE_URL; - + String failureUrl = DEFAULT_FAILURE_URL; Integer failureType = determineFailureType(exception).getType(); if (failureType != null) { - defaultFailureUrl += defaultFailureUrl.lastIndexOf("?") > 0 ? "&" : "?" + "error=" + failureType; + failureUrl += (failureUrl.lastIndexOf("?") > 0 ? "&" : "?") + "error=" + failureType; } - return defaultFailureUrl; + return failureUrl; } private LoginError determineFailureType(AuthenticationException exception) { - if (exception.getMessage() == "验证码为空"){ + if ("验证码为空".equals(exception.getMessage())){ return LoginError.CAPTCHANOTFOUND; - } else if (exception.getMessage() == "验证码过期") { + } else if ("验证码过期".equals(exception.getMessage())) { return LoginError.CAPTCHAEXPIRED; - } else if (exception.getMessage() == "验证码不正确") { + } else if ("验证码不正确".equals(exception.getMessage())) { return LoginError.CAPTCHAERROR; } else if (exception instanceof UsernameNotFoundException) { return LoginError.USERNAMENOTFOUND; @@ -82,14 +75,4 @@ private LoginError determineFailureType(AuthenticationException exception) { return LoginError.FAILURE; } - - public String getDefaultFailureUrl() { - return defaultFailureUrl; - } - - @Override - public void setDefaultFailureUrl(String defaultFailureUrl) { - super.setDefaultFailureUrl(defaultFailureUrl); - } - } diff --git a/src/main/resources/static/js/header.js b/src/main/resources/static/js/header.js index 0407df9..e7f3afb 100755 --- a/src/main/resources/static/js/header.js +++ b/src/main/resources/static/js/header.js @@ -24,4 +24,4 @@ document.writeln(""); document.writeln(""); document.writeln(""); document.writeln(""); -document.writeln(""); +document.writeln(""); diff --git a/src/main/resources/static/js/jquery.request.js b/src/main/resources/static/js/jquery.request.js index f4260aa..8002161 100755 --- a/src/main/resources/static/js/jquery.request.js +++ b/src/main/resources/static/js/jquery.request.js @@ -42,7 +42,10 @@ function request (url, method, data = {}, contentType, back){ error: error, headers: xhr.getAllResponseHeaders() }); - return typeof back === "function" && back(null); + var message = xhr.responseJSON && xhr.responseJSON.msg + ? xhr.responseJSON.msg + : "请求失败:" + (xhr.status || status) + " " + (error || xhr.statusText || ""); + return typeof back === "function" && back({code: 1, msg: message}); } }); }; @@ -57,4 +60,3 @@ function postAjaxRequst (url, data, callBack) { return typeof callBack == "function" && callBack(res) }) }; - diff --git a/src/main/resources/templates/common/common.html b/src/main/resources/templates/common/common.html index a3b0585..0207713 100644 --- a/src/main/resources/templates/common/common.html +++ b/src/main/resources/templates/common/common.html @@ -99,8 +99,8 @@ - + - \ No newline at end of file + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index c5f8704..2dd486d 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -2,7 +2,7 @@ - JavaSecLab v1.4 - 一款Java安全综合漏洞平台 + JavaSecLab v1.5 - 一款Java安全综合漏洞平台 @@ -64,7 +64,7 @@
  • - admin + admin
    p { height: 40px; } + + .home-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; + padding: 14px 16px; + background: #fff; + border: 1px solid #f2f2f2; + border-radius: 6px; + box-shadow: 0 1px 6px rgba(15, 23, 42, .04); + } + + .home-title { + min-width: 240px; + } + + .home-title h2 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 600; + color: #303133; + } + + .home-title p { + margin: 0; + color: #777; + font-size: 13px; + } + + .home-search { + width: 360px; + max-width: 45%; + } + + .home-stats { + display: flex; + gap: 10px; + } + + .home-stat { + min-width: 82px; + padding: 8px 12px; + background: #f8fbff; + border: 1px solid #e8f3ff; + border-radius: 6px; + text-align: center; + } + + .home-stat strong { + display: block; + color: #1e9fff; + font-size: 18px; + line-height: 22px; + } + + .home-stat span { + color: #777; + font-size: 12px; + } + + .module-hidden { + display: none !important; + } + + .module-empty { + display: none; + margin-bottom: 15px; + padding: 16px; + color: #777; + text-align: center; + background: #fff; + border: 1px dashed #d9d9d9; + border-radius: 6px; + } + + .module-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; + } + + .module-cards > .layui-col-md6 { + width: auto; + padding: 0 !important; + float: none; + } + + @media screen and (max-width: 992px) { + .home-toolbar { + flex-direction: column; + align-items: stretch; + } + + .home-search { + width: 100%; + max-width: 100%; + } + + .home-stats { + justify-content: space-between; + } + + .module-cards { + grid-template-columns: 1fr; + } + }
    +
    +
    +

    JavaSecLab 漏洞靶场

    +

    按漏洞类型快速进入场景,支持模块名、关键词和英文缩写检索。

    +
    + +
    +
    10漏洞分类
    +
    37+功能场景
    +
    V1.5当前版本
    +
    +
    +
    没有找到匹配模块,试试 SQL、RCE、SSTI、登录、组件 等关键词。
    -
    +
    @@ -332,81 +482,44 @@
    -
    -
    -
    - - RCE -
    +
    +
    + + RCE +
    -
    -
    -
    - - -
    -
    -
    - - 逻辑漏洞 -
    -
    -
    + 请求交互安全
    @@ -451,6 +564,59 @@
    +
    + +
    + +
    +
    + + 逻辑漏洞 +
    + +
    @@ -746,7 +912,7 @@
  • -

    V1.4

    +

    V1.5

    支持漏洞模块:

    • 跨站脚本攻击、跨站请求伪造、CORS、JSONP、URL重定向、XFF伪造、拒绝服务、XPATH注入
    • @@ -793,9 +959,42 @@

      2024-03

      miniTab.listen(); + var moduleLinks = $('.layuimini-main a[layuimini-content-href]'); + $('#moduleSearch').on('input', function () { + var keyword = $.trim($(this).val()).toLowerCase(); + var visibleCount = 0; + + moduleLinks.each(function () { + var link = $(this); + var text = [ + link.text(), + link.attr('data-title'), + link.attr('layuimini-content-href') + ].join(' ').toLowerCase(); + var item = link.closest('.layuimini-qiuck-module, .layuimini-qiuck-module-two, .layuimini-qiuck-module-five'); + var matched = !keyword || text.indexOf(keyword) > -1; + + item.toggleClass('module-hidden', !matched); + if (matched) { + visibleCount++; + } + }); + + $('.layui-card').each(function () { + var card = $(this); + if (card.find('.layuimini-qiuck-module, .layuimini-qiuck-module-two, .layuimini-qiuck-module-five').length > 0) { + card.toggleClass('module-hidden', card.find('.layuimini-qiuck-module:not(.module-hidden), .layuimini-qiuck-module-two:not(.module-hidden), .layuimini-qiuck-module-five:not(.module-hidden)').length === 0); + } + }); + + $('#moduleEmpty').toggle(visibleCount === 0); + }); + // echarts 窗口缩放自适应 window.onresize = function () { - echartsRecords.resize(); + if (typeof echartsRecords !== 'undefined') { + echartsRecords.resize(); + } } }); diff --git a/src/main/resources/templates/vul/components/fastjson.html b/src/main/resources/templates/vul/components/fastjson.html index 7f7dbb6..aa9e0ae 100644 --- a/src/main/resources/templates/vul/components/fastjson.html +++ b/src/main/resources/templates/vul/components/fastjson.html @@ -236,7 +236,7 @@

      安全代码

      closeBtn: 1, area: ['1116px', '572px'], // 宽高可以根据需要调整 shadeClose: true, - content: '
      ' + content: '
      ' }); }); diff --git a/src/main/resources/templates/vul/components/jackson.html b/src/main/resources/templates/vul/components/jackson.html index 90ea6c8..1d0ec82 100644 --- a/src/main/resources/templates/vul/components/jackson.html +++ b/src/main/resources/templates/vul/components/jackson.html @@ -157,7 +157,7 @@

      安全代码

      closeBtn: 1, area: ['1116px', '572px'], // 宽高可以根据需要调整 shadeClose: true, - content: '
      ' + content: '
      ' }); }); diff --git a/src/main/resources/templates/vul/components/log4j2.html b/src/main/resources/templates/vul/components/log4j2.html index da5d594..da7da3b 100644 --- a/src/main/resources/templates/vul/components/log4j2.html +++ b/src/main/resources/templates/vul/components/log4j2.html @@ -175,9 +175,11 @@

      缺陷代码

      CodeMirror(document.getElementById("vulLog4j2"), Object.assign({}, cmConfig, { value: vulLog4j2 })); - CodeMirror(document.getElementById("safeLog4j2"), Object.assign({}, cmConfigSafe, { - value: safeLog4j2 - })); + if (document.getElementById("safeLog4j2")) { + CodeMirror(document.getElementById("safeLog4j2"), Object.assign({}, cmConfigSafe, { + value: safeLog4j2 + })); + } }); @@ -196,7 +198,7 @@

      缺陷代码

      closeBtn: 1, area: ['1116px', '572px'], // 宽高可以根据需要调整 shadeClose: true, - content: '
      ' + content: '
      ' }); }); diff --git a/src/main/resources/templates/vul/components/shiro.html b/src/main/resources/templates/vul/components/shiro.html index 705a636..0dc6b76 100644 --- a/src/main/resources/templates/vul/components/shiro.html +++ b/src/main/resources/templates/vul/components/shiro.html @@ -124,9 +124,11 @@

      缺陷代码

      CodeMirror(document.getElementById("vulShiro"), Object.assign({}, cmConfig, { value: vulShiro })); - CodeMirror(document.getElementById("safeShiro"), Object.assign({}, cmConfigSafe, { - value: safeShiro - })); + if (document.getElementById("safeShiro")) { + CodeMirror(document.getElementById("safeShiro"), Object.assign({}, cmConfigSafe, { + value: safeShiro + })); + } }); @@ -145,7 +147,7 @@

      缺陷代码

      closeBtn: 1, area: ['975px', '672px'], // 宽高缩小为0.9倍 shadeClose: true, - content: '
      ' + content: '
      ' }); });