[하루한줄] CVE-2024-5716: Logsign 사의 Unified SecOps Platform에서 발견된 두 가지 취약점

URL

https://www.zerodayinitiative.com/blog/2024/7/1/getting-unauthenticated-remote-code-execution-on-the-logsign-unified-secops-platform

Target

  • Logsign Unified SecOps Platform < 6.4.8

Explain

Logsign Unified SecOps Platform은 기업 보안 운영을 위한 통합 솔루션입니다. Python 기반의 웹 서버로, 사용자는 API를 통해 웹 서버와 상호 작용할 수 있습니다.

Unified SecOps Platform에서 발견된 두 가지 취약점을 결합하면 공격자는 인증 우회 및 원격 코드 실행이 가능합니다.

CVE-2024-5716 - Authentication Bypass

첫 번째 취약점은 비밀번호 재설정 메커니즘에 있습니다.

서버에서 사용자 비밀번호 재설정 요청을 받으면 해당 사용자와 연결된 이메일 주소로 6자리 숫자로 구성된 reset_code를 전송합니다.

사용자는 reset_code를 통해 비밀번호를 재설정할 수 있습니다. 하지만, reset_code 검증에 대한 횟수 제한이 없어 공격자는 bruteforce 공격을 통해 사용자 계정을 탈취할 수 있습니다.

@flask_app.route('/settings/forgot_password', methods=['POST'])
@decs.json_api
def forgot_password():
    try:
        data = json.loads(request.data)
        username = data.get('username', None)  # [1]
        user_conf = user_store.fetch_by_username(username)
        _now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        if user_conf and not user_conf.get('disabled'):
            if not user_conf.get('from_ldap'):  # [2]
                reset_code = random_int(6)
                user_store.save(user_conf.get('uid'), {"reset_code": reset_code, "reset_time": time.time(), "reset_datetime": _now})
                subject = "Logsign - Your single-use code"
                content = '''
                    We received your request for a single-use code to use with your Logsign account.\n
                    Your single-use code is: <b>%s</b>\n
                    If you didn't request this code, you can safely ignore this email.\n
                ''' % reset_code
            else:
                subject = "Logsign - LDAP User Forgot Password"
                content = '''
                    We are writing to inform you that the user account associated with %s is under the ownership and management of your LDAP server.\n
                    As such, we would like to emphasize that no updates or changes can be made to this account from Logsign.\n\n
                    Any actions related to this account should be directed to your designated LDAP administrators. 
                ''' % username
            for response_conf in _get_default_notification_config():
                action_obj = {
                    'uid': random_string(16),
                    'datetime': _now,
                    'response_uid': response_conf.get('uid'),
                    'integration': response_conf.get('integration'),
                    'method': 'send',
                    'is_mss': False,
                    'args': {
                        'content': content,
                        'subject': subject,
                        'to_users': [{'user_uid': user_conf.get('uid'), 'is_local': True}]
                    }
                }
                redis_client.hset('action_queue', action_obj.get('uid'), json.dumps(action_obj))
    except Exception as err:
        return {'message': 'Reset password failed: %s' % str(err), 'success': False}
    return {'message': 'If that username is in our database, we will send you an email to reset your password', 'success': True}

[1] POST 요청에서 username매개변수를 가져옵니다. [2] 사용자가 from_ldap아닌 경우, reset_code를 6자리 랜덤 숫자로 설정합니다.

def random_int(N):
    return "".join((str(random.randint(0, 9)) for x in range(N)))

서버는 usernamereset_code 쌍을 저장하고, 사용자의 이메일로 reset_code를 전송합니다.

@flask_app.route('/settings/verify_reset_code', methods=['POST'])
@decs.json_api
def verify_reset_code():
    try:
        data = json.loads(request.data)
        if not data.get('reset_code'):
            return {'message': 'Failed', 'success': False}
        username = data.get('username', None)
        if not username:
            return {'message': 'Failed', 'success': False}
        user_conf = user_store.fetch_by_username(username)
        if not user_conf or user_conf.get('disabled'):
            return {'message': 'Failed', 'success': False}
        if time.time() - user_conf.get('reset_time', 0) >= 180:  # [1]
            return {'message': 'Password reset timeout', 'success': False}
        if str(data.get('reset_code', '')) == user_conf.get('reset_code'):
            verification_code = '%s-%s-%s' % (random_string(8), random_string(8), random_string(8))
            user_store.save(user_conf.get('uid'), {"reset_code": None, "reset_time": 0, "reset_datetime": 0,
                                                   "reset_verified": True, "verify_code_time": time.time(), "verification_code": verification_code})
        else:
            return {'message': 'Verification code invalid', 'success': False}
    except Exception as err:
        return {'message': 'Verify reset code failed: %s' % str(err), 'success': False}
    return {'message': 'Success', 'success': True, 'verification_code': verification_code}

[1] 서버는reset_code의 만료 시간을 3분으로 설정하지만, 검증 시도 횟수 제한이 없습니다.

이로 인해, 공격자는 admin 사용자의 비밀번호 재설정 요청을 보내고, 3분 내에 bruteforce 공격으로 reset_code를 찾아낼 수 있습니다.

만약 3분 내에 성공하지 못하면, 새로운 reset_code를 요청해 공격을 반복할 수 있습니다.

CVE-2024-5717 - Post Auth Command Injection

두 번째 취약점은 사용자 입력 값을 적절히 검증하지 않아 발생했습니다.

인증된 사용자만 입력 값을 보낼 수 있지만, CVE-2024-5716 취약점을 통해 인증 메커니즘을 우회할 수 있습니다.

# /opt/logsign-api/settings_api.py
@settings_pages.route('/demo_mode', methods=['POST'])
@decs.check_profile_only_case('set_settings_demo_mode')
@decs.json_api
[1] @decs.authenticate
def demo_mode_toggle():
    data = json.loads(request.data)
    if data.get('enable'):
        for line in local['screen']['-ls'](retcode=None).split('\n'):
            if line.find('demo_mode') != -1:
                return {'message': 'already running', 'success': False}
[2]        local['screen']['-dmS']['demo_mode']['sh']['-c'][
            'cd /opt/log_generator; ./start.sh -e ' + escapeshellarg(
                data.get('list')) + ' -o es -s localhost -p 10 2>&1']()
        config.set('demo_mode_scenarios', data)
        config.save()
    else:
        local['screen']['-S']['demo_mode']['-p']['0']['-X']['quit']()
    return {'message': 'success', 'success': True}

list 매개변수를 통해 사용자 입력 값을 escapeshellarg 함수에 전달한 후, 이를 쉘 명령어에 추가합니다.

# /opt/logsign-commons/python/logsign/commons/helpers.pyc
def escapeshellarg(s):
    return "\\'".join((char for char in s.split("'")))

escapeshellarg 함수는 단일 인용 부호(')만 이스케이프 하기 때문에, 백틱(\), 세미콜론(;) 등의 특수문자를 사용하면 command injection 취약점이 발생합니다.


{
    "enable":true,
    "list": "`whoami > /tmp/Proof.txt`"
}

따라서, 공격자는 CVE-2024-5716 취약점을 통해 관리자 비밀번호를 재설정한 후 로그인하여, CVE-2024-5717 취약점을 통해 root 권한으로 임의의 명령어를 실행할 수 있습니다.

Reference