1. What is JWT?
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and independent method for securely transferring information as JSON objects between parties. Because this information is digitally signed, it can be authenticated and trusted. JWT can be signed using secret (HMAC algorithm) or “public/private key pair of RSA or ECDSA”.
While JWT can be encrypted to provide secrecy between parties, we will focus on signed tokens. Signed Tokens verify the integrity of the claims contained therein, while encrypted Tokens hide claims made by these other parties. When a public/private key pair is used to signa token, Signature Also Certifies that only the party holding the private key is the party signing it.
From website
2. What can JWT do?
1, authorization,
This is the most common scenario using JWT. Once the user is logged in, each subsequent request will include JWT, allowing the user to access the routes, services, and resources that the token allows. Single sign-on is a feature of JWT that is widely used today because of its low overhead and ease of use in different domains.
2. Information exchange
JSON Web Tokens are a great way to securely transfer information between parties. Because the JWT can be signed (for example, using a public/private key pair), you can be sure that the sender is in person. In addition, because the signature is computed using headers and payloads, you can verify that the content has not been tampered with.
3. Problems revealed by Session-based authentication
1, the overhead
After each user is authenticated by our application, our application makes a record on the server to facilitate the identification of the user’s next request. Generally speaking, the session is stored in memory. However, as the number of authenticated users increases, the cost of the server will increase significantly.
2. Scalability
Certification records, user authentication, the service side do if certification records are stored in the memory, this means that the next time the user request must also request on this server, so as to get authorization of resources, so that in a distributed application, the corresponding limits the ability of the load balancer, it also means that limits the application ability of extension.
3, CSRF
Because user identification is based on cookies, if cookies are intercepted, users will be vulnerable to CSRF attacks.
1, 200 copies of many out-of-print e-books that can not be bought 2, 30G video materials inside the safety factory 3, 100 copies of SRC documents 4, common security comprehensive questions 5, CTF contest classic topic analysis 6, the full set of toolkit 7, emergency response notes
JWT profile
4. JWT certification process
First, the front end sends its user name and password to the back-end interface via a Web form. This process is typically an HTTP POST request. The recommended method is ssl-encrypted transmission (HTTPS protocol) to avoid sensitive information being sniffed.
The backend checks the user name and password successfully and forms a JWT Token.
The back end returns the JWT string to the front end as the result of a successful login. The front-end can save the returned results in localStorage or sessionStorage, and the front-end can delete the saved JWT when logging out.
The front end puts the JWT into the Authorization field in the HTTP Header on each request.
The backend verifies the validity of the JWT passed from the front-end.
After the authentication is successful, the backend uses the user information contained in JWT to perform other logical operations and returns corresponding results.
5. Structure of JWT
5.1 Composition of the token: header.payload-signature
1. Header
What’s the Payload?
3. Signature
5.2, the Header
The header usually consists of two parts: the type of token (that is, JWT) and the signature algorithm used, such as HMAC SHA256 (default, HS256) or RSA (RS256). It uses Base64 encoding to form the first part of the JWT structure.
Note: Base64 is an encoding, that is, it can be translated back to its original form; it is not an encryption process.
Something like this:
{" ALG ": "HS256", // encryption algorithm "TYP ": "JWT" // type}Copy the code
5.3, content
The second part of the token is the payload, which contains the declaration. Declarations are declarations about entities (usually users) and other data. Again, it uses Base64 encoding to form the second part of the JWT structure
Declarations registered in the standard (recommended but not mandatory) :
1. Iss: JWT issuer
2, sub: JWT for users
3. Aud: The party receiving JWT
Exp: expiration time of JWT. This expiration time must be greater than the issue time
NBF: define before what time the JWT will be unavailable
6. Iat: issue time of JWT
7. Jti: The unique identifier of JWT, which is mainly used as a one-time token to avoid replay attacks
Something like this:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
Copy the code
5.4, Signature
The first two parts are encoded in Base64, meaning that the front end can unlock the information inside. Signature needs to use the encoded Header, Payload, and a key provided by us, and then use the Signature algorithm specified in the Header (HS256) to sign. The purpose of the signature is to ensure that JWT has not been tampered with
For example, HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), ‘secret’);
The test environment
Jwt. IO/is a collection of…
Maven: com.auth0 / java-jwt / 3.3.0
Maven: org.bitbucket. B_c/jose4j / 0.6.3
Connect2id implementation of nimbus-Jose-jwt: “Maven: com.nimbusds/nimbus-Jose-jwt / 5.7”
“Maven: IO. Jsonwebtoken/jjwt-root / 0.11.1”
Maven: IO. Fusionauth/FusionAuth-jwt / 3.5.0
Maven: “Maven: IO. Vertx/vertx-auth-jwt / 3.5.1”
This article is a brief introduction, but each JWT library has its own advantages and disadvantages. If you are interested, you can study the test environment posted here by a big shot, which includes all of these:
https://github.com/monkeyk/MyOIDC/
Black box testing
For convenience, the WebGoat Range will be used for testing
Using the WebGoat Java source code directly to start the range is a bit of a hassle, because the JDK version is more demanding.
To build WebGoat using Docker, enter the following commands:
Docker Goat/Webgoat -8.0: V8.1.0 Docker pull Webgoat/WebWolf: V8.1.0 Docker pull Webgoat/goatandwolf: v8.1.0 docker images docker run - d - p 8888:8888 - p, 8080:8080-9090: p 9090 webgoat/goatandwolf: v8.1.0Copy the code
After startup, access:
http://192.168.189.128:8080/WebGoat/start.mvc#lesson/JWT.lesson/3
It is this voting function that switches users to get tokens:
Click the recycle bin icon to reset the vote as prompted
Not a valid JWT token, please try again
Corresponding packet:
You know, only the administrator can reset the vote
Modify the first two parts of the token (“. Code segmentation), Base64 decoding respectively:
Change the value of “alg” to NONE and the value of “admin” to true
After concatenating the modified Base64 encoding, send the packet again:
Error: remove ‘=’ sign:
Or error, and then directly delete the third paragraph, pay attention to keep the “. No. :
The vote reset was successful.
Code audit
Most articles on the Internet only describe the steps of the black box test, with little explanation on the code level of this vulnerability. Next, we will use debugging to have an in-depth understanding of the principle of this vulnerability.
A snippet of WebGoat shooting range code for this bug:
Generate access_token with /JWT/votings/login interface
Verify access_token with /JWT/ Votings
The JWT library used here is the JJWT mentioned above, and the dependency can be viewed from the POM file:
<! -- JJWT --> <dependency> <groupId> IO. Jsonwebtoken </groupId> <artifactId> JJWT </artifactId> <version>0.9.0</version> <scope>test</scope> </dependency>Copy the code
Here we directly use SpringBoot to build a simple test environment, convenient debugging.
Specific code:
package com.example.demo; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwt; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.impl.TextCodec; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import java.time.Duration; import java.time.Instant; import java.util.Date; @RestController public class test { public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory"); private static String validUsers = "zzz"; @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("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("/verify") @ResponseBody public String getVotes(@CookieValue(value = "access_token", required = false) String accessToken) { if (StringUtils.isEmpty(accessToken)) { return "no login"; } else { try { Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken); Claims claims = (Claims) jwt.getBody(); String user = (String) claims.get("user"); if ("zzz".equals(user)) { return "zzz"; } if ("admin".equals(user)) { return "admin"; } } catch (Exception e) { return e.toString(); } } return "login"; }}Copy the code
Access_token:
access
http://127.0.0.1:8080/login?user=zzz
Get access_token
To access the
http://127.0.0.1:8080/verify
Breakpoint location is at check check parsing:
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Copy the code
Follow up Jwts. Parser ()
Look at the constructor of DefaultJwtParser:
public DefaultJwtParser() {
// Clock:
/ / github.com/jwtk/jjwt#j… // Custom Clock Support // If the above setAllowedClockSkewSeconds isn’t sufficient for your needs, the timestamps created during parsing for timestamp comparisons can be obtained via a custom time source. Call the JwtParserBuilder’s setClock method with an implementation of the io.jsonwebtoken.Clock interface.
For example: // If the clock tilt allowed by the above Settings is not sufficient For your needs, you can obtain a custom timestamp from a custom time source. The JwtParserBuilder’s setClock method is called using an implementation of the IO.jsonWebToken. Clock interface. Such as:
// Clock clock = new MyClock();
// Jwts.parserBuilder().setClock(myClock)
this.clock = DefaultClock.INSTANCE;
this.allowedClockSkewMillis = 0L;
}
Copy the code
Go back to
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);
Copy the code
This JWT_PASSWORD is defined at the top:
public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");
Copy the code
Then follow up
\io\jsonwebtoken\impl\DefaultJwtParser.class#setSigningKey()
This assert.hastext () just checks to see if it’s a String:
And then this line:
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
That’s why I Base64 encoded the Key, okay
Give to DefaultJwtParser. KeyBytes:
Then return the DefaultJwtParser object:
Back to:
To continue with the DefaultJwtParser#parse method, first examine the String String:
Then initialize the Header, Payload, and Digest:
DelimiterCount:
The following for loop converts the entire token to a char array:
Var7 is the token char array. Var8 is the number of characters in the array.
Now look at this for loop:
for(int var9 = 0; var9 < var8; ++var9) { char c = var7[var9]; / / "." If (c == '.') {Copy the code
// Save the split character first
CharSequence tokenSeq = Strings.clean(sb);
// tokens are the first segment:
"EyJhbGciOiJIUzUxMiJ9", "eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoienp6In0" String token = tokenSeq! = null ? tokenSeq.toString() : null;Copy the code
// delimiterCount the Header or Payload into the corresponding field
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
Copy the code
// Every time you encounter “. DelimiterCount is incremented by one, and the StringBuilder object is emptied
++delimiterCount;
sb.setLength(0);
} else {
Copy the code
// When the for loop ends, the StringBuilder holds the third segment:
"pntCuTlybllQYsg4BHtgNEQrEmheFalhhv6VEU_CFZ18MP8uvVBCLYK0RjAkIZpyF7KLlBhYzdhN20i8zdMU3A" sb.append(c); }}Copy the code
Moving on:
If the number of delimiters is not 2, the JWT format is wrong and an exception is thrown.
Next, pass the filtered third paragraph to the Digest Digest:
Then look at the if judgment:
// base64UrlEncodedHeader is not null if (base64UrlEncodedHeader! = null) {/ / Base64 decoding base64UrlEncodedHeader payload. = TextCodec BASE64URL. DecodeToString (base64UrlEncodedHeader);Copy the code
// Read the contents of the Header into the Map key-value pair
Map<String, Object> m = this.readValue(payload);
// Here is the key branch, depending on whether base64UrlEncodedDigest is empty or not
if (base64UrlEncodedDigest ! = null) { header = new DefaultJwsHeader(m); } else { header = new DefaultHeader(m); }Copy the code
As you can see, the default “ALG” is HS512.
Now, replace it with POC and try:
access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
Copy the code
The first two Base64 encoding segments corresponding to the modification:
“Alg” changed to NONE:
Change “user” to admin:
And then according to the breakpoint, quickly go back to where we were:
Because of this if judgment:
// base64UrlEncodedHeader is not null if (base64UrlEncodedHeader! = null) {/ / Base64 decoding base64UrlEncodedHeader payload. = TextCodec BASE64URL. DecodeToString (base64UrlEncodedHeader);Copy the code
// Read the contents of the Header into the Map key-value pair
Map<String, Object> m = this.readValue(payload);
// Here is the key branch, depending on whether base64UrlEncodedDigest is empty or not
if (base64UrlEncodedDigest ! = null) { header = new DefaultJwsHeader(m); } else { header = new DefaultHeader(m); }Copy the code
We have removed the third paragraph, base64UrlEncodedDigest is null, so we go to the else branch:
header = new DefaultHeader(m);
DefaultHeader constructor:
\io\jsonwebtoken\impl\DefaultHeader.class
public DefaultHeader(Map<String, Object> map) {
super(map);
}
Copy the code
Super:
\io\jsonwebtoken\impl\JwtMap.class
public JwtMap(Map<String, Object> map) {
Assert.notNull(map, "Map argument cannot be null.");
this.map = map;
}
Copy the code
So, the instantiated DefaultHeader object gives the header:
Moving on:
To follow up
\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
Copy the code
Follow up with the getAlgorithmFromHeader method of this class:
Take a look at these two lines:
Assert.notNull(header, "header cannot be null.");
return header.getCompressionAlgorithm();
Copy the code
NotNull (header, “Header cannot be null.”);
Assert that assertion
Is to determine if an actual value is expected and throw an exception if it is not.
The assertion here, which is self-implemented by the JJWT library, follows this notNull method:
\io\jsonwebtoken\lang\Assert.class#notNull()
Copy the code
Check whether the passed Object is null.
Look at the return header. GetCompressionAlgorithm ();
Let’s start with:
Returns null
Specific follow-up to see
\io\jsonwebtoken\impl\DefaultHeader.class#getCompressionAlgorithm()
Copy the code
{“alg”:”none”}), run quickly to try it out:
Returns “None”, whereas in the source code, it returns null.
Go back to
\io\jsonwebtoken\impl\compression\DefaultCompressionCodecResolver.class#resolveCompressionCodec()
Copy the code
Then null is returned below:
Go back to
\io\jsonwebtoken\impl\DefaultJwtParser.class#parse()
Null is returned to compressionCodec and continues:
CompressionCodec is null, else branch:
This is the Base64 decoding of the second encoded character stored in the Payload.
Results after treatment:
Payload = {" iAT ":1636552183,"admin":"false","user":"admin"}Copy the code
Moving on:
Take a look at this claim:
\io\jsonwebtoken\Claims.class
A declaration that corresponds to the Payload standard registered (recommended but not mandatory) :
Iss: JWT issuer
Sub: The user JWT is targeting
Aud: The side receiving the JWT
Exp: indicates the expiration time of the JWT. The expiration time must be greater than the issue time
NBF: Define before what time the JWT is unavailable
Iat: issue time of JWT
Jti: Unique IDENTIFIER of the JWT. It is used as a one-time token to avoid replay attacks
Now look at this if:
Payload Payload Payload payload payload payload payload payload
Payload payload payload payload payload payload payload payload
Then, using the constructor of DefaultClaims, we get the standard Claims:
DefaultClaims Instance object gives claims to:
Moving on:
Since the third paragraph was deleted from our POC:
access_token=eyJhbGciOiJub25lIn0.eyJpYXQiOjE2MzY1NTIxODMsImFkbWluIjoiZmFsc2UiLCJ1c2VyIjoiYWRtaW4ifQ.
Copy the code
So, don’t go into the if body.
Moving on:
L here enclosing allowedClockSkewMillis default to 0, so allowSkew to false
Then, if the claim is not null, enter the if body and verify the validity period, which is obviously not null:
Get the current time, and then call the getExpiration method of DefaultClaims to getExpiration exceptions:
Passing “exp” to call DefaultClaims’s get method:
JwtMap get method
Under review
Exp: indicates the expiration time of the JWT. The expiration time must be greater than the issue time
“Exp” is not found here, return null to DefaultJwtParser’s parse method:
Skip the if judgment and move on:
Follow up:
Similarly, this time I’m going to take NBF.
Under review
NBF: Define before what time the JWT is unavailable
Also returns null:
Moving on:
As can be seen from the method name, verify expected Claims, follow up look at:
Default is null, so return:
Back to:
if (base64UrlEncodedDigest ! = null) { return new DefaultJws((JwsHeader)header, body, base64UrlEncodedDigest); } else { return new DefaultJwt((Header)header, body); }Copy the code
The key branch, Digest, we deleted
Return a new DefaultJwt object:
DefaultJwt constructor:
public DefaultJwt(Header header, B body) { this.header = header; this.body = body; } return to Jwt Jwt = jwts.parser ().setsigningKey (JWT_PASSWORD).parse(accessToken);Copy the code
Take a look at the returned Jwt instance object:
Moving on:
To follow up
\io\jsonwebtoken\impl\DefaultJwt.class#getBody()
DefaultClaims returns the Payload portion directly to the DefaultClaims instance object:
When done, user is overwritten:
Recall that we haven’t seen the “alg” branch so far, so let’s try this instead:
All right, just delete the third part and you’re done.
conclusion
This paper only makes an analysis on an old validation vulnerability of JWT. JWT supports a variety of symmetric and asymmetric algorithms. JWE and JWS of JWT correspond to encryption/decryption and signature/check respectively. The learning process is very interesting.