文章发表于:
https://www.ch35tnut.site/zh-cn/vulnerability/cve-2023-42820-jumpserver-pwd-reset-vuln/
也可阅读原文跳转
基本信息
jumpserver中第三方库向用户公开了随机库所用的seed,并且没有限制重置密码接口的次数,导致攻击者可以获取到随机库的随机种子并尝试预测重置密码的验证码,进而重置任意用户密码。利用该漏洞需要已知用户名和对应的邮箱。
指纹
hunter
web.title="jumpserver"
影响版本
-
CVE-2023-42820
v2.24 - v3.6.4
环境搭建
参考https://github.com/jumpserver/Dockerfile,将版本改为3.6.4,使用docker启动即可。
技术分析&调试
补丁分析
漏洞在commit 0eba6d2175ab752399c5aee2dbaaf311bf0a398d修复,查看补丁,可知在apps/common/utils/random.py#random_string
处增加了 random.seed()调用,同时对 apps/users/models/user.py#generate_reset_token
生成token改为增加了 random.seed
调用的random_string函数
到这里只能隐约猜到是一个密码学有关的漏洞,应该可以通过爆破利用。
技术分析在前两天有师傅写出了分析,才恍然大悟。
根据jumpserver最新re-auth复现(伪随机经典案例)可知在本例的jumpserver中在如下地方生成重置密码时的验证码,其中使用了本次修复的函数 random_string生成6位,范围为000000-999999的数字验证码
opt/jumpserver/apps/authentication/api/password.py
def create(self, request, *args, **kwargs):
token = request.GET.get('token')
userinfo = cache.get(token)
if not userinfo:
return HttpResponseRedirect(reverse('authentication:forgot-previewing'))
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
username = userinfo.get('username')
form_type = serializer.validated_data['form_type']
code = random_string(6, lower=False, upper=False)
with open("/tmp/code","a") as f:
f.write(code+"n")
other_args = {}
target = serializer.validated_data[form_type]
if form_type == 'sms':
query_key = 'phone'
target = target.lstrip('+')
else:
query_key = form_type
user, err = self.is_valid_user(username=username, **{query_key: target})
if not user:
return Response({'error': err}, status=400)
subject = '%s: %s' % (get_login_title(), _('Forgot password'))
context = {
'user': user, 'title': subject, 'code': code,
}
message = render_to_string('authentication/_msg_reset_password_code.html', context)
other_args['subject'], other_args['message'] = subject, message
SendAndVerifyCodeUtil(target, code, backend=form_type, **other_args).gen_and_send_async()
return Response({'data': 'ok'}, status=200)
在大学学习c语言的rand函数时,我们知道如果不对其显式使用srand函数播种的话,则每次运行程序随机出来的结果是一样的,因为rand使用的种子在计算机启动时就不会再变化了,所以我们要使用srand函数产生种子并进行播种,来使得rand函数的结果不一样。实际上计算机中的随机数不是真正的随机数,而是伪随机数,计算机根据传入的种子经过某些运算得出结果。对于一个进程,随机的种子确定则随机数也确定。这个规律在python中也一样,对于同样的seed及同样的随机次数,一定会生成同样的数字。
Python 3.11.4 (tags/v3.11.4:d2340ef, Jun 7 2023, 05:45:37) [MSC v.1934 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
> import random
1010) > random.seed(
> random.random()
0.6710054770408643
➜ chestnut python3
Python 3.11.4 (main, Jun 7 2023, 10:13:09) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
> import random
1010) > random.seed(
> random.random()
0.6710054770408643
>
虽然我们现在知道了这个漏洞应该源于伪随机数,但我们如果没办法获取到随机时所设置的种子,也没办法预测随即结果。
下面就是这个漏洞的精华所在,根据文章所述。django使用了第三方库djiango-simple-captcha
库来生成验证码,在这个库生成的时候会有如下逻辑:在usr/local/lib/python3.11/site-packages/captcha/views.py$captcha_image
中,通过传入的key设置random.seed()
,而传入的key则是浏览器向后端请求图片的路径,下图的key为 c83d66ac7dca7e2189ad17a9a3e532f2e87d5c07
def captcha_image(request, key, scale=1):
if scale == 2 and not settings.CAPTCHA_2X_IMAGE:
raise Http404
try:
store = CaptchaStore.objects.get(hashkey=key)
except CaptchaStore.DoesNotExist:
# HTTP 410 Gone status so that crawlers don't index these expired urls.
return HttpResponse(status=410)
random.seed(key) # Do not generate different images for the same key
text = store.challenge
也就是我们可以通过图片的url间接知道jumpserver随机时所使用的种子,也就是说,通过图片地址我们可以获取到种子,如果生成验证码时所在的进程和这个生成图片验证码的进程在同一个进程,那么我们就可以通过获取到的种子和使用jumpserver生成验证码的算法来预测jumpserver生成重置密码的验证码。这样我们可以在很少的时间(次数)内预测到重置密码的验证码,进而重置密码。但仅仅这么简单吗?
在jumpserver中使用了 gunicorn
,它会使用master进程fork worker进程,处理用户请求,类似于nginx,所以即使我们通过单次的图片请求获取到了random的种子,处理重置密码请求的进程可能不是被获取到种子的进程,这样预测出来的验证码和重置密码时生成的不会一样。
➜ chestnut docker top 808b | grep python
root 5704 5674 0 11:08 ? 00:00:02 python jms start web
root 5864 5704 0 11:08 ? 00:00:01 /usr/local/bin/python /usr/local/bin/celery -A ops flower -logging=info --url_prefix=/core/flower --auto_refresh=False --max_tasks=1000 --state_save_interval=600000
root 5865 5704 0 11:08 ? 00:00:00 /usr/local/bin/python /usr/local/bin/gunicorn jumpserver.asgi:application -b 0.0.0.0:8080 -k uvicorn.workers.UvicornWorker -w 4 --max-requests 10240 --max-requests-jitter 2048 --access-logformat %(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s --access-logfile -
root 5867 5865 0 11:08 ? 00:00:01 /usr/local/bin/python /usr/local/bin/gunicorn jumpserver.asgi:application -b 0.0.0.0:8080 -k uvicorn.workers.UvicornWorker -w 4 --max-requests 10240 --max-requests-jitter 2048 --access-logformat %(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s --access-logfile -
root 5868 5865 0 11:08 ? 00:00:02 /usr/local/bin/python /usr/local/bin/gunicorn jumpserver.asgi:application -b 0.0.0.0:8080 -k uvicorn.workers.UvicornWorker -w 4 --max-requests 10240 --max-requests-jitter 2048 --access-logformat %(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s --access-logfile -
root 5873 5865 0 11:08 ? 00:00:02 /usr/local/bin/python /usr/local/bin/gunicorn jumpserver.asgi:application -b 0.0.0.0:8080 -k uvicorn.workers.UvicornWorker -w 4 --max-requests 10240 --max-requests-jitter 2048 --access-logformat %(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s --access-logfile -
root 5874 5865 0 11:08 ? 00:00:02 /usr/local/bin/python /usr/local/bin/gunicorn jumpserver.asgi:application -b 0.0.0.0:8080 -k uvicorn.workers.UvicornWorker -w 4 --max-requests 10240 --max-requests-jitter 2048 --access-logformat %(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s --access-logfile -
在文章中提到可以有两种办法:
-
并发同时发送多个请求,通过大量请求使得每个
gunicorn
进程都会接收到图片验证码的请求,从而将所有进程的seed设置为同一个种子,这样后续重置密码时无论哪个进程接收到的请求,该进程的seed都是已知的。 -
通过某种办法将
gunicorn
打挂,并监测服务状态,当服务响应码从502变为200时,说明进程恢复正常,这时通过少量请求即可将所有目标进程的seed设置为我们已知的值。并发发送大量请求让我想起了k8s环境中,pod切换IP的场景,
时间比较紧(太笨了),没看出来哪里可以造成crash,在使用第一种办法的时候,发现会有些许问题
-
在发送几千个请求之后,通过图片验证码请求触发重置密码时,后端会返回这个验证码不正确
-
在发送请求之后,经过测试使用seed生成和jumpserver相同的code需要经过几万次
所以在这里讨巧,手动重启core container(模拟crash了gunicorn的场景),而后通过请求验证码图片设置seed,经过测试成功的次数范围为200+,即成功生成和jumpserver一样的重置密码验证码需要random两百多次。
小结
回过头看文章所说的 随机深度
,按照我的理解就是在生成code时,所在进程已经random了几次,随机次数越多,预测时所要的次数也就越多,因为相同的seed经过相同的次数生成的随机数是一样的,在生成验证码和random_string函数中均有多次使用random类函数生成随机数,所以才需要循环计算进行碰撞。在漏洞利用过程中,除了前面说的覆盖seed的问题,jumpserver还会验证请求url里面的token以及POST的body里面需要携带csrf token,这些都可以通过url解析以及xpath从请求响应中获取到。在一个就是生成重置密码验证码后,这个验证码有60秒有效期,过了60秒之后再去碰撞后端会返回验证码已过期,需要重新生成。在有个问题就是图片验证码涉及到数学运算,粗略看来验证码应该可以通过ocr库进行识别并计算,实现自动化获取图片地址、设置seed、计算验证码等,这个只能等节后仔细研究了。
文章写的比较赶时间,仅作研究,另外祝大家双节快乐!
如果有师傅有能直接crash gunicorn的办法,希望能指教一下
参考链接
https://github.com/jumpserver/jumpserver/security/advisories/GHSA-7prv-g565-82qp
原文始发于微信公众号(闲聊趣说):CVE-2023-42820 jumpserver 任意用户密码重置漏洞浅析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论