欢迎关注我的微信公众号《壳中之魂》,查看更多网安文章
使用docker搭建环境
docker run -it -p 127.0.0.1:80:8888 -p 127.0.0.1:8080:8080 -p 127.0.0.1:9090:9090 -e TZ=Europe/Amsterdam webgoat/goatandwolf:v8.2.2
在这里有个小问题,由于后面需要用到burpsuite,但是burpsuite抓不到本地包,这个环境又不能使用本机IP登录,所以最好把127.0.0.1换成本机ip,如10.10.10.10
随后打开页面进行注册
http://192.168.3.25:8080/WebGoat/login#
如需审计源码则可以去github下载
Release v8.2.2 · WebGoat/WebGoat · GitHub
身份验证绕过(逻辑漏洞)
题目要求我们回答安全问题来重置密码,然而答案明显不知道,先随便输入然后抓包
首先确定了接口,为/WebGoat/auth-bypass/verify-account
将包发送出去,查看结果
先要绕过只需要把secQuestion0/1换为secQuestion2/3即可
查看源码
@PostMapping(path = "/auth-bypass/verify-account", 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 failed(this)
.feedback("verify-account.cheated")
.output("Yes, you guessed correctly, but see the feedback message")
.build();
}
// else
if (verificationHelper.verifyAccount(Integer.valueOf(userId), (HashMap) submittedAnswers)) {
userSessionData.setValue("account-verified-id", userId);
return success(this)
.feedback("verify-account.success")
.build();
} else {
return failed(this)
.feedback("verify-account.failed")
.build();
}
}
当首先看到有两个if体
第一个if体:
这个if体是当用户输入了正确答案,正如我所说,这两个问题的密码正常情况下是回答不出来的,除非读了源码,所以didUserLikelylCheat方法就是判断输入的内容是否是源码的答案,若是则提醒用户作弊
重点在第二个if体内
第二个if题对用户输入进行比对,若用户成功绕过则显示verify-account.success的内容,否则显示verify-account.failed的内容
而问题出在verifyAccount方法,步入方法
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;
}
可以很轻易地看出,判断的语句是获取secQuestion0&1,然后和答案进行比对吗,若比对错误则返回false,除此以外任何情况都返回true,所以我们传入secQuestion2&3不在判断范围内,所以不会进入secQuestion0&1的if判断,直接就返回true
JWT Token漏洞
失效的身份验证会导致攻击者破译密码、密钥或者会话令牌或者利用其他开发漏洞暂时或长久地冒充其他用户的身份,导致攻击者可以执行受害者用户的任何操作。
什么是JWT
Json Web Token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519。
该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景,是目前最流行的跨域认证解决方案。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
JWT 的原理
JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。
{ "姓名": "张三", "角色": "管理员", "到期时间": "2018年7月1日0点0分" }
以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。
服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
JWT 的数据结构
实际当中 JWT 长这个样子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNURkh1YiIsImlhdCI6MTUxNjIzOTAyMn0.Y2PuC-D6SfCRpsPN19_1Sb4WPJNkJr7lhG6YzA8-9OQ
它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的
JWT 的三个部分依次如下:
写成一行,就是下面的样子。
Header.Payload.Signature
每个部分最后都会使用 base64URLEncode方式进行编码
#!/usr/bin/env python function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
Header
Header 部分是一个 JSON 对象,描述 JWT 的元数据,以上面的例子,使用 base64decode 之后:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
{ "alg": "HS256", "typ": "JWT" }
header部分最常用的两个字段是alg和typ。
alg属性表示token签名的算法(algorithm),最常用的为 HMAC SHA256(写成 HS256)和RSA算法
typ属性表示这个token的类型(type),JWT 令牌统一写为JWT。
Payload
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。
除了官方字段,还可以在这个部分定义私有字段,以上面的例子为例,将 payload 部分解 base64 之后:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNURkh1YiIsImlhdCI6MTUxNjIzOTAyMn0
{ "sub": "1234567890", "name": "CTFHub", "iat": 1516239022 }
注意:JWT 默认是不会对 Payload 加密的,也就意味着任何人都可以读到这部分JSON的内容,所以不要将私密的信息放在这个部分
Signature
Signature 部分是对前两部分的签名,防止数据篡改
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
参考链接
https://en.wikipedia.org/wiki/JSON_Web_Token
https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
JWT Token
失效的身份验证的靶场为(A2)Broken Authentication中的JWT tokens关卡,打开关卡,可以看到题目的要求
(尝试修改你的token以获得管理员权限,并重置投票)
首先先以guest的身份进行重置投票
进行抓包
可以发现,access_token的值为空
把身份切换为tom,尝试重置投票
再次进行抓包
成功抓到access_token的值,把access_token的值拿去解密,可以用webgoat自带的,也可以用JSON Web Tokens - jwt.io
可以看到在paylaod中存在admin的属性,同时被赋予了false
通过bp抓包,发现重置投票的接口为/WebGoat/JWT/votings,知道了接口的位置,直接在源代码中搜索
代码如下所示
@PostMapping("/JWT/votings")
@ResponseBody
public AttackResult resetVotes(@CookieValue(value = "access_token", required = false) String accessToken) {
if (StringUtils.isEmpty(accessToken)) {
return failed(this).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 failed(this).feedback("jwt-only-admin").build();
} else {
votes.values().forEach(vote -> vote.reset());
return success(this).build();
}
} catch (JwtException e) {
return failed(this).feedback("jwt-invalid-token").output(e.toString()).build();
}
}
}
稍加观察就可以发现,boolean isAdmin = Boolean.valueOf((String) claims.get("admin"));这句话获取了claims中的admin参数的值,如果为admin则可以重置投票,否则显示jwt-only-admin
Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken);通过这行代码我们知道,加密的密钥为JWT_PASSWORD,追踪JWT_PASSWORD,查看值
public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory");
可以看到了是吧victory进行base64的加密,所以理论上我们想要进行重置投票,只需要将admin的属性置为true即可
事先说明,这样是错的,原因处在了Secret key上,再回到重置投票的代码
步入方法,发现竟然又把传入的值解码了,所以最后的密钥其实就是JWT_PASSWORD本身
public JwtParser setSigningKey(String base64EncodedKeyBytes) {
Assert.hasText(base64EncodedKeyBytes, "signing key cannot be null or empty.");
this.keyBytes = TextCodec.BASE64.decode(base64EncodedKeyBytes);
return this;
}
最终答案
eyJhbGciOiJIUzUxMiJ9.ew0KICAiYWRtaW4iIDogInRydWUiLA0KICAiaWF0IiA6IDE2NDE1MjMwMDUsDQogICJ1c2VyIiA6ICJUb20iDQp9._YNHC6_Ts4CPo3DWL94ErcAiVtgxgOCpuAGJCQQKc7fTZrlYxTfNMtUvfI5QHTAxpzMj3rGfN92oiDFLFEl-Pw
然而当我们不知道密钥或者猜解不出来怎么办呢?webgoat其实给了我们另一种解决方法
We can change the admin claim to false but then signature will become invalid. How do we end up with a valid signature? Looking at the RFC specification alg: none is a valid choice and gives an unsecured JWT. Let’s change our token:
简单来说,就是RFC标准允许我们把加密方式设置为none,那么不需要知道密钥,只需要把admin设置为true即可
但是后面最后一行有提示,说要在最后面加上一个.,否则会显示jwt非法
eyJhbGciOiJub25lIn0.ew0KICAiYWRtaW4iIDogInRydWUiLA0KICAiaWF0IiA6IDE2NDE1MjMwMDUsDQogICJ1c2VyIiA6ICJUb20iDQp9.
通过上述分析可以发现,若研发人员在Web应用中对基于JWT的身份认证方案设计不当,攻击者可通过猜解、爆破等方式获取JWT Token,进而使身份认证方案防御失效。
防御
签名算法的验证固定在后端,不以 JWT 里的算法为标准。假设每次验证 JWT ,验证算法都靠读取 Header 里面的 alg 属性来判断的话,攻击者只要签发一个 "alg: none" 的 JWT ,就可以绕过验证了。
具体场景选择合适的算法,例如分布式场景下,建议选择 RS256 。
除了需要保证密钥不被泄露之外,密钥的强度也应该重视,防止遭到字典攻击。
JWS 方式下的 JWT 的 Payload 信息是公开的,不能将敏感信息保存在这里,如有需要,请使用 JWE 。
JWT 过期时间建议设置足够短,过期后重新使用 refresh_token 刷新获取新的 token 。
用户名爆破漏洞
密码重置界面
重置密码需要输入用户名和密保问题,当输入的用户名错误时则会显示非法用户,因此可以对用户名进行爆破
查看源码
if (validAnswer == null) {
return failed(this).feedback("password-questions-unknown-user").feedbackArgs(username).build();
} else if (validAnswer.equals(securityQuestion)) {
return success(this).build();
}
很好理解,就是验证发现当用户不存在即告诉用户用户名非法,想要修复也很简单,只告诉用户用户名或者密码错误即可
越权漏洞
登录页面,要求我们以tom的身份登录,然而我们不知道tom的密码,先进行密码找回
密码找回要求我们输入要找回的邮箱
发一个正常的密码重置包给我们的邮箱
发现url似乎存在身份码,猜测如果我们能够获得tom的身份码就可以代替tom进行水平越权的密码重置
我们输入tom的邮箱,然后抓包
发现包被发往了了红框内的地址(也就是8080端口),通过修改发送的地址为我们自己的服务器(也就是webwolf的9090端口)
然而我们不是tom,所以是收不到此邮件的,但是我们可以收到此http包
bfda7dcb-bdec-41a6-8913-40ecee20e720
将此身份码替换url中的,然后输入新的密码tom
使用密码tom登录,成功
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。