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.